mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
v3.4.0 (#1527)
* 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:
@@ -331,6 +331,8 @@ The script creates a timestamped DB backup before making changes and prompts for
|
||||
|
||||
For production, put TREK behind a TLS-terminating reverse proxy. TREK uses WebSockets for real-time sync, so the proxy **must** support WebSocket upgrades on `/ws`.
|
||||
|
||||
If you use the MCP addon, the proxy must also pass the `Mcp-Session-Id` header through in both directions on `/mcp` — Nginx and Caddy do this by default, but a proxy that strips it makes every tool call open a new session instead of reusing one. See the [Reverse Proxy wiki page](https://github.com/mauriceboe/TREK/wiki/Reverse-Proxy) for details.
|
||||
|
||||
<details>
|
||||
<summary>Nginx</summary>
|
||||
|
||||
@@ -368,6 +370,19 @@ server {
|
||||
proxy_set_header Host $host;
|
||||
proxy_read_timeout 86400;
|
||||
}
|
||||
|
||||
# Only needed if you use the MCP addon. Responses are Server-Sent Events,
|
||||
# so buffering must be off or tool results arrive late.
|
||||
location /mcp {
|
||||
proxy_pass http://localhost:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 3600s;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -430,7 +445,7 @@ Caddy handles TLS and WebSockets automatically.
|
||||
| `DEMO_MODE` | Enable demo mode (hourly data resets) | `false` |
|
||||
| `UNSPLASH_ACCESS_KEY` | Optional Unsplash Access Key for trip-cover and place-image search. Without one, TREK uses Unsplash's unauthenticated endpoint, which some datacenter/VPS IPs are blocked from. Get a free key at [unsplash.com/developers](https://unsplash.com/developers). Overrides any per-admin key set in Admin > Settings (where it can also be configured instead). | — |
|
||||
| `MCP_RATE_LIMIT` | Max MCP API requests per user per minute | `300` |
|
||||
| `MCP_MAX_SESSION_PER_USER` | Max concurrent MCP sessions per user | `20` |
|
||||
| `MCP_MAX_SESSION_PER_USER` | Max concurrent MCP sessions per user. At the cap, the least-recently-active session is closed to make room | `20` |
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { dismissSystemNotices } from './helpers'
|
||||
|
||||
// Trip lifecycle (core): from the dashboard, open the new-trip modal, name the
|
||||
// trip, submit, and confirm it shows up on the dashboard. Exercises the whole
|
||||
@@ -7,18 +8,23 @@ import { test, expect } from '@playwright/test'
|
||||
test('create a trip and see it on the dashboard', async ({ page }) => {
|
||||
await page.goto('/dashboard')
|
||||
|
||||
// The release notice greets a freshly seeded user and its backdrop eats the click below.
|
||||
await dismissSystemNotices(page)
|
||||
|
||||
// The "+ New Trip" card is always rendered in the default (planned) filter.
|
||||
await page.locator('.add-trip-card').click()
|
||||
|
||||
// Scope to the shared Modal (.modal-backdrop). Its form has no in-form submit
|
||||
// button (the primary action lives in the footer), so click it explicitly
|
||||
// rather than pressing Enter. The Create button is the slate primary button;
|
||||
// Cancel is the bordered one.
|
||||
const modal = page.locator('.modal-backdrop')
|
||||
// Scope to the shared Modal (.trek-modal-backdrop — namespaced so content blockers
|
||||
// don't hide a generic .modal-backdrop). Its form has no in-form submit button (the
|
||||
// primary action lives in the footer), so click it explicitly rather than pressing
|
||||
// Enter. The Create button is the slate primary button; Cancel is the bordered one.
|
||||
const modal = page.locator('.trek-modal-backdrop')
|
||||
await expect(modal).toBeVisible()
|
||||
|
||||
// Target Title by placeholder: the cover-image search inputs sit above it, so
|
||||
// input[type=text].first() is the photo search box, not the field we want.
|
||||
const title = `E2E Trip ${Date.now()}`
|
||||
await modal.locator('input[type="text"]').first().fill(title)
|
||||
await modal.getByPlaceholder('e.g. Summer in Japan').fill(title)
|
||||
await modal.getByRole('button', { name: 'Create New Trip' }).click()
|
||||
|
||||
await expect(page.getByText(title).first()).toBeVisible({ timeout: 15_000 })
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* Dismiss the release-notice modal (SystemNoticeHost), which greets a freshly seeded
|
||||
* user on first load and covers the dashboard — its backdrop swallows clicks aimed at
|
||||
* anything underneath, `.add-trip-card` included.
|
||||
*
|
||||
* The X only appears on the notice's last page, so page through first. Dismissal is
|
||||
* persisted server-side per user, but each spec gets a fresh DB, so every spec that
|
||||
* touches the dashboard has to clear it.
|
||||
*/
|
||||
export async function dismissSystemNotices(page: Page): Promise<void> {
|
||||
const next = page.getByRole('button', { name: /next/i })
|
||||
for (let i = 0; i < 6 && (await next.isVisible().catch(() => false)); i++) {
|
||||
if (!(await next.isEnabled())) break
|
||||
await next.click()
|
||||
}
|
||||
|
||||
const dismiss = page.getByRole('button', { name: 'Dismiss' })
|
||||
if (await dismiss.isVisible().catch(() => false)) await dismiss.click()
|
||||
await dismiss.waitFor({ state: 'detached' }).catch(() => {})
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { test, expect, devices } from '@playwright/test'
|
||||
import { dismissSystemNotices } from './helpers'
|
||||
|
||||
// Tablet regression guard for #1432 — the places list must scroll under a touch swipe.
|
||||
//
|
||||
// A tablet is a coarse-pointer device at a *desktop* viewport width, so the width-based
|
||||
// "is this mobile" check that 3.2.1 shipped left `draggable` armed on iPad: the swipe
|
||||
// became an HTML5 drag and raised the drop-to-import overlay instead of scrolling. Drag
|
||||
// is now gated on `(pointer: coarse)` (useIsTouch), and only a real device context proves
|
||||
// it — a jsdom unit test cannot express "coarse pointer at 834px".
|
||||
//
|
||||
// Needs WebKit (`npx playwright install webkit`, plus libmanette-0.2-0 and libwoff1 on
|
||||
// Debian/Ubuntu). WebKit is the right engine here, not a nicety: every browser on iPadOS
|
||||
// is WebKit underneath, which is why the reporter saw this in all three they tried.
|
||||
test.use({ ...devices['iPad Pro 11'] })
|
||||
|
||||
test('#1432 iPad: places list is scrollable, not draggable', async ({ page }) => {
|
||||
await page.goto('/dashboard')
|
||||
|
||||
await dismissSystemNotices(page)
|
||||
|
||||
await page.locator('.add-trip-card').click()
|
||||
const createBtn = page.getByRole('button', { name: 'Create New Trip' })
|
||||
await expect(createBtn).toBeVisible()
|
||||
const title = `iPad 1432 ${Date.now()}`
|
||||
await page.getByPlaceholder('e.g. Summer in Japan').fill(title)
|
||||
await createBtn.click()
|
||||
|
||||
await page.getByText(title).first().click()
|
||||
await expect(page).toHaveURL(/\/trips\/\d+/)
|
||||
await expect(page.locator('.leaflet-container')).toBeVisible({ timeout: 20_000 })
|
||||
|
||||
const tripId = page.url().match(/\/trips\/(\d+)/)![1]
|
||||
|
||||
// Seed enough places for the list to overflow and actually need scrolling.
|
||||
for (let i = 1; i <= 25; i++) {
|
||||
const res = await page.request.post(`/api/trips/${tripId}/places`, {
|
||||
data: { name: `Place ${i}`, lat: 48.85 + i * 0.01, lng: 2.35 + i * 0.01 },
|
||||
})
|
||||
expect(res.ok(), `seed place ${i}`).toBeTruthy()
|
||||
}
|
||||
await page.reload()
|
||||
await expect(page.locator('.leaflet-container')).toBeVisible({ timeout: 20_000 })
|
||||
await expect(page.getByText('Place 1').first()).toBeVisible({ timeout: 20_000 })
|
||||
|
||||
// The context must really be the one from the bug report: coarse pointer, desktop
|
||||
// width. If either is wrong, everything below proves nothing.
|
||||
const env = await page.evaluate(() => ({
|
||||
coarse: window.matchMedia('(pointer: coarse)').matches,
|
||||
width: window.innerWidth,
|
||||
}))
|
||||
expect(env.coarse, 'iPad reports a coarse primary pointer').toBe(true)
|
||||
expect(env.width, 'iPad sits above the 768px "mobile" breakpoint').toBeGreaterThanOrEqual(768)
|
||||
|
||||
// 1. Rows must not be draggable — a draggable row is what swallowed the scroll gesture.
|
||||
const row = page.locator('div[draggable]').filter({ hasText: 'Place 1' }).first()
|
||||
await expect(row).toHaveAttribute('draggable', 'false')
|
||||
|
||||
// 2. The list must scroll, and no drop-to-import overlay may appear.
|
||||
const scroller = page.locator('div[draggable]').first().locator('xpath=ancestor::div[@class="trek-stagger"]')
|
||||
const before = await scroller.evaluate(el => el.scrollTop)
|
||||
const box = (await scroller.boundingBox())!
|
||||
await page.touchscreen.tap(box.x + box.width / 2, box.y + 40)
|
||||
await scroller.evaluate(el => el.scrollBy(0, 200))
|
||||
const after = await scroller.evaluate(el => el.scrollTop)
|
||||
expect(after, 'places list scrolled').toBeGreaterThan(before)
|
||||
await expect(page.getByText('Drop to import')).toHaveCount(0)
|
||||
|
||||
// 3. Drag being off means the arrow buttons are the only reorder affordance left —
|
||||
// they must be visible (they were opacity:0 above 767px).
|
||||
const arrowOpacity = await page.evaluate(() => {
|
||||
const el = document.querySelector('.reorder-buttons')
|
||||
return el ? getComputedStyle(el).opacity : 'absent'
|
||||
})
|
||||
expect(['1', 'absent']).toContain(arrowOpacity)
|
||||
|
||||
// 4. The iPad must still get the desktop two-pane layout — isMobile stayed width-based.
|
||||
await expect(page.locator('.leaflet-container')).toBeVisible()
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { dismissSystemNotices } from './helpers'
|
||||
|
||||
// The day-plan reorder arrows are hover-revealed on desktop. The rule that did that was
|
||||
// dead for a long time — it targeted `.place-row .reorder-btns`, neither of which exists
|
||||
// (the component renders `.reorder-buttons` inside an unclassed row), so the buttons sat
|
||||
// at opacity:0 with no way to reveal them.
|
||||
//
|
||||
// That is not merely "invisible": opacity:0 still hit-tests, so every itinerary row and
|
||||
// note carried an invisible, fully clickable target that silently reordered the trip.
|
||||
// These cases pin both halves — hidden means non-interactive, hover means visible.
|
||||
test('desktop: reorder arrows are hidden-and-inert until the row is hovered', async ({ page }) => {
|
||||
await page.goto('/dashboard')
|
||||
await dismissSystemNotices(page)
|
||||
|
||||
await page.locator('.add-trip-card').click()
|
||||
const modal = page.locator('.trek-modal-backdrop')
|
||||
await expect(modal).toBeVisible()
|
||||
const title = `Reorder ${Date.now()}`
|
||||
await modal.getByPlaceholder('e.g. Summer in Japan').fill(title)
|
||||
await modal.getByRole('button', { name: 'Create New Trip' }).click()
|
||||
|
||||
await page.getByText(title).first().click()
|
||||
await expect(page).toHaveURL(/\/trips\/\d+/)
|
||||
await expect(page.locator('.leaflet-container')).toBeVisible({ timeout: 20_000 })
|
||||
|
||||
// Two places on day 1, so the day plan renders rows carrying reorder arrows.
|
||||
const tripId = page.url().match(/\/trips\/(\d+)/)![1]
|
||||
const daysRes = await (await page.request.get(`/api/trips/${tripId}/days`)).json()
|
||||
const dayId = (daysRes.days ?? daysRes)[0].id
|
||||
for (const name of ['Alpha', 'Beta']) {
|
||||
const res = await page.request.post(`/api/trips/${tripId}/places`, {
|
||||
data: { name, lat: 48.85, lng: 2.35 },
|
||||
})
|
||||
const body = await res.json()
|
||||
await page.request.post(`/api/trips/${tripId}/days/${dayId}/assignments`, {
|
||||
data: { place_id: body.place?.id ?? body.id },
|
||||
})
|
||||
}
|
||||
await page.reload()
|
||||
await expect(page.locator('.leaflet-container')).toBeVisible({ timeout: 20_000 })
|
||||
|
||||
const row = page.locator('.dp-row').filter({ hasText: 'Alpha' }).first()
|
||||
await expect(row).toBeVisible({ timeout: 20_000 })
|
||||
const arrows = row.locator('.reorder-buttons')
|
||||
|
||||
// Unhovered: invisible AND inert — a click there must not land on the button.
|
||||
const idle = await arrows.evaluate(el => {
|
||||
const cs = getComputedStyle(el)
|
||||
const r = el.getBoundingClientRect()
|
||||
const hit = document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2)
|
||||
return { opacity: cs.opacity, hitsArrow: !!hit?.closest('.reorder-buttons') }
|
||||
})
|
||||
expect(idle.opacity, 'arrows hidden until hover').toBe('0')
|
||||
expect(idle.hitsArrow, 'hidden arrows must not swallow clicks').toBe(false)
|
||||
|
||||
// Hovered: revealed and clickable.
|
||||
await row.hover()
|
||||
await expect(arrows).toHaveCSS('opacity', '1')
|
||||
await expect(arrows).toHaveCSS('pointer-events', 'auto')
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { dismissSystemNotices } from './helpers'
|
||||
|
||||
// Open a trip into the planner: create a trip, open it from the dashboard, and
|
||||
// confirm the trip planner (TripPlannerPage — the app's largest page) actually
|
||||
@@ -6,12 +7,17 @@ import { test, expect } from '@playwright/test'
|
||||
test('open a trip and land in the planner with a map', async ({ page }) => {
|
||||
await page.goto('/dashboard')
|
||||
|
||||
// The release notice greets a freshly seeded user and its backdrop eats the click below.
|
||||
await dismissSystemNotices(page)
|
||||
|
||||
// Create a trip to open.
|
||||
await page.locator('.add-trip-card').click()
|
||||
const modal = page.locator('.modal-backdrop')
|
||||
const modal = page.locator('.trek-modal-backdrop')
|
||||
await expect(modal).toBeVisible()
|
||||
// Target Title by placeholder: the cover-image search inputs sit above it, so
|
||||
// input[type=text].first() is the photo search box, not the field we want.
|
||||
const title = `E2E Planner ${Date.now()}`
|
||||
await modal.locator('input[type="text"]').first().fill(title)
|
||||
await modal.getByPlaceholder('e.g. Summer in Japan').fill(title)
|
||||
await modal.getByRole('button', { name: 'Create New Trip' }).click()
|
||||
|
||||
// Open it from the dashboard.
|
||||
|
||||
@@ -502,6 +502,11 @@ export const adminApi = {
|
||||
pluginActivate: (id: string, consent?: boolean) => apiClient.post(`/admin/plugins/${id}/activate`, consent ? { consent: true } : {}).then(r => r.data),
|
||||
pluginDeactivate: (id: string) => apiClient.post(`/admin/plugins/${id}/deactivate`).then(r => r.data),
|
||||
pluginUpdate: (id: string) => apiClient.post(`/admin/plugins/${id}/update`).then(r => r.data),
|
||||
// Re-trust a ROTATED author signing key and update, in ONE call. `publicKey` is the
|
||||
// full key the admin was shown (not a fingerprint): the server compares it exactly, so
|
||||
// it can refuse if the registry entry was re-keyed again since the dialog rendered.
|
||||
pluginRetrust: (id: string, version: string, publicKey: string) =>
|
||||
apiClient.post(`/admin/plugins/${id}/retrust`, { version, publicKey }).then(r => r.data),
|
||||
pluginUninstall: (id: string, deleteData: boolean) => apiClient.post(`/admin/plugins/${id}/uninstall`, { deleteData }).then(r => r.data),
|
||||
pluginRescan: () => apiClient.post('/admin/plugins/rescan').then(r => r.data),
|
||||
pluginUpload: (file: File) => { const fd = new FormData(); fd.append('file', file); return postMultipart('/admin/plugins/upload', fd) },
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { server } from '../../../tests/helpers/msw/server'
|
||||
import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/render'
|
||||
import { resetAllStores } from '../../../tests/helpers/store'
|
||||
@@ -119,3 +119,366 @@ describe('AdminPluginsPanel — Discover modal, operator-egress pill', () => {
|
||||
expect(screen.queryByText(/hosts you add/i)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* #1523. The row's ⋯ menu used to be an in-flow `absolute` div, and PageSidebar — the
|
||||
* panel's ancestor — is `overflow-hidden`. On the lower rows of a long plugin list the
|
||||
* menu was clipped mid-way, taking Delete with it: the plugin became uninstallable from
|
||||
* the UI. It must escape every overflow ancestor, and flip up when the bottom is tight.
|
||||
*/
|
||||
describe('AdminPluginsPanel — row ⋯ menu is never clipped (#1523)', () => {
|
||||
const withRepo = plugin({ source_repo: 'trek/gotify', operatorEgress: false })
|
||||
const realRect = HTMLButtonElement.prototype.getBoundingClientRect
|
||||
afterEach(() => { HTMLButtonElement.prototype.getBoundingClientRect = realRect })
|
||||
|
||||
/** Put the ⋯ button wherever we want in an 800px-tall viewport. */
|
||||
function stubTriggerAt(top: number) {
|
||||
window.innerHeight = 800
|
||||
window.innerWidth = 1200
|
||||
HTMLButtonElement.prototype.getBoundingClientRect = function () {
|
||||
return { top, bottom: top + 34, left: 1100, right: 1134, width: 34, height: 34, x: 1100, y: top, toJSON: () => ({}) } as DOMRect
|
||||
}
|
||||
}
|
||||
|
||||
async function openRowMenu() {
|
||||
mockList(withRepo)
|
||||
const { container } = render(<AdminPluginsPanel />)
|
||||
fireEvent.click(await screen.findByTestId('plugin-row-menu-btn-trek-gotify'))
|
||||
return { container, menu: screen.getByTestId('plugin-row-menu-trek-gotify') }
|
||||
}
|
||||
|
||||
it('FE-COMP-PLUGINS-MENU-001: renders every action, including Delete', async () => {
|
||||
stubTriggerAt(100)
|
||||
await openRowMenu()
|
||||
|
||||
for (const label of [/restart/i, /error log/i, /allowed hosts/i, /source repository/i, /report an issue/i, /delete/i]) {
|
||||
expect(screen.getByText(label)).toBeInTheDocument()
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-MENU-002: is portaled out of the panel, so no overflow ancestor can clip it', async () => {
|
||||
stubTriggerAt(100)
|
||||
const { container, menu } = await openRowMenu()
|
||||
|
||||
// THE regression guard: living inside the panel is exactly what got it clipped.
|
||||
expect(container.contains(menu)).toBe(false)
|
||||
expect(menu.parentElement).toBe(document.body)
|
||||
expect(menu.style.position).toBe('fixed')
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-MENU-003: hangs below the ⋯ when there is room', async () => {
|
||||
stubTriggerAt(100)
|
||||
const { menu } = await openRowMenu()
|
||||
|
||||
expect(menu.style.top).toBe('138px') // trigger bottom (134) + 4
|
||||
expect(menu.style.bottom).toBe('')
|
||||
expect(menu.style.right).toBe('66px') // viewport (1200) - trigger right (1134)
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-MENU-004: flips upward for a row near the bottom — the #1523 case', async () => {
|
||||
stubTriggerAt(700) // 66px of room below: the six-item menu would run off-screen
|
||||
const { menu } = await openRowMenu()
|
||||
|
||||
expect(menu.style.bottom).toBe('104px') // viewport (800) - trigger top (700) + 4
|
||||
expect(menu.style.top).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Signature status (#plugins). TREK has always verified author signatures and TOFU-pinned
|
||||
* the key — and never showed any of it, so a successfully-installed UNSIGNED plugin looked
|
||||
* identical to a signed one, forever.
|
||||
*
|
||||
* The two tests that matter most here are the ones guarding the override: a re-trust is
|
||||
* offered for a ROTATED key (benign explanation) and for NOTHING else. A signature that
|
||||
* doesn't verify means the bytes are not what the author signed, and there is no story
|
||||
* where the right answer is letting the admin wave it through.
|
||||
*/
|
||||
function registryEntry(over: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 'trek-gotify', name: 'Gotify', author: 'Acme', description: 'Push', repo: 'acme/gotify',
|
||||
type: 'integration', latest: '2.0.0', minTrekVersion: null, reviewedAt: null,
|
||||
screenshotUrl: null, signed: true, authorPublicKey: 'NEWKEYbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
function mockPanel(p: Record<string, unknown>, entry: Record<string, unknown> | null = registryEntry()) {
|
||||
server.use(
|
||||
http.get('*/api/admin/plugins', () => HttpResponse.json({ enabled: true, devLink: false, plugins: [p] })),
|
||||
http.get('*/api/admin/plugins/registry', () => HttpResponse.json(entry ? [entry] : [])),
|
||||
)
|
||||
}
|
||||
|
||||
describe('AdminPluginsPanel — signature badges', () => {
|
||||
it('FE-COMP-PLUGINS-SIG-001: a registry plugin with a pinned key reads as Signed', async () => {
|
||||
mockPanel(plugin({ source_repo: 'acme/gotify', signed: true, keyFingerprint: 'AAAAAAAA…BBBBBBBB' }))
|
||||
render(<AdminPluginsPanel />)
|
||||
expect(await screen.findByText('Signed')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Unsigned')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-SIG-002: a registry plugin with no key reads as Unsigned', async () => {
|
||||
mockPanel(plugin({ source_repo: 'acme/gotify', signed: false, keyFingerprint: null }))
|
||||
render(<AdminPluginsPanel />)
|
||||
expect(await screen.findByText('Unsigned')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// The precedence rule. `signed` derives from the pinned key, sideloaded from source_repo
|
||||
// — so they are NOT mutually exclusive in the data, and a sideloaded plugin genuinely has
|
||||
// no key. Rendering "Unsigned" NEXT TO "Sideloaded" would double up on a plugin whose
|
||||
// badge already says something strictly stronger, diluting the amber into wallpaper.
|
||||
it('FE-COMP-PLUGINS-SIG-003: a sideloaded plugin shows Sideloaded and NO trust badge', async () => {
|
||||
mockPanel(plugin({ source_repo: 'local:upload', signed: false }))
|
||||
render(<AdminPluginsPanel />)
|
||||
expect(await screen.findByText('Sideloaded')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Unsigned')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Signed')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-SIG-004: a dev-linked plugin shows Dev-Link and NO trust badge', async () => {
|
||||
mockPanel(plugin({ source_repo: 'local:link', signed: false }))
|
||||
render(<AdminPluginsPanel />)
|
||||
expect(await screen.findByText('Dev-Link')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Unsigned')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('AdminPluginsPanel — a refused update', () => {
|
||||
const blocked = (code: string) =>
|
||||
plugin({
|
||||
source_repo: 'acme/gotify', signed: true, keyFingerprint: 'OLDKEYaa…aaaaaaaa',
|
||||
updateBlock: { code, detail: 'the signing key changed', version: '2.0.0' },
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-SIG-005: the row keeps showing WHY, instead of the reason dying with a toast', async () => {
|
||||
mockPanel(blocked('SIGNATURE_KEY_CHANGED'))
|
||||
render(<AdminPluginsPanel />)
|
||||
expect(await screen.findByText(/update blocked/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// The block describes the version that was REFUSED. Once the registry offers a newer one,
|
||||
// it describes an artifact nobody is being offered anymore — so it reads as stale and the
|
||||
// admin can simply re-attempt.
|
||||
it('FE-COMP-PLUGINS-SIG-006: the block goes quiet once a NEWER version is on offer', async () => {
|
||||
mockPanel(blocked('SIGNATURE_KEY_CHANGED'), registryEntry({ latest: '3.0.0' }))
|
||||
render(<AdminPluginsPanel />)
|
||||
await screen.findByText('Gotify')
|
||||
await waitFor(() => expect(screen.queryByText(/update blocked/i)).not.toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-SIG-007: Review opens the re-trust dialog for a ROTATED key', async () => {
|
||||
mockPanel(blocked('SIGNATURE_KEY_CHANGED'))
|
||||
render(<AdminPluginsPanel />)
|
||||
fireEvent.click(await screen.findByRole('button', { name: /review/i }))
|
||||
|
||||
// Both fingerprints, so the admin can compare them against what the author tells them.
|
||||
expect(await screen.findByText(/key it was installed with/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/key it is offering now/i)).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /trust the new key/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// D2, at the UI. An invalid signature means the bytes are not what the author signed.
|
||||
// There is no override — not a disabled button, not one behind a confirm. The ABSENCE of
|
||||
// an escape hatch is the feature. (The server refuses it too; this is belt and braces.)
|
||||
it('FE-COMP-PLUGINS-SIG-008: an INVALID signature offers NO re-trust affordance at all', async () => {
|
||||
mockPanel(blocked('SIGNATURE_INVALID'))
|
||||
render(<AdminPluginsPanel />)
|
||||
fireEvent.click(await screen.findByRole('button', { name: /review/i }))
|
||||
|
||||
await screen.findByText(/do not match the author's signature/i)
|
||||
expect(screen.queryByRole('button', { name: /trust the new key/i })).not.toBeInTheDocument()
|
||||
// ...and it does not even show the key comparison, which would imply a choice exists.
|
||||
expect(screen.queryByText(/key it is offering now/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-SIG-009: an unsigned-downgrade refusal offers no override either', async () => {
|
||||
mockPanel(blocked('SIGNATURE_MISSING'))
|
||||
render(<AdminPluginsPanel />)
|
||||
fireEvent.click(await screen.findByRole('button', { name: /review/i }))
|
||||
|
||||
await screen.findByText(/ships no signature/i)
|
||||
expect(screen.queryByRole('button', { name: /trust the new key/i })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-SIG-010: confirming a re-trust re-pins AND updates in ONE call', async () => {
|
||||
let body: unknown = null
|
||||
mockPanel(blocked('SIGNATURE_KEY_CHANGED'))
|
||||
server.use(
|
||||
http.post('*/api/admin/plugins/trek-gotify/retrust', async ({ request }) => {
|
||||
body = await request.json()
|
||||
return HttpResponse.json({ version: '2.0.0', activated: true, newPermissions: [], newEgress: [] })
|
||||
}),
|
||||
)
|
||||
render(<AdminPluginsPanel />)
|
||||
fireEvent.click(await screen.findByRole('button', { name: /review/i }))
|
||||
fireEvent.click(await screen.findByRole('button', { name: /trust the new key/i }))
|
||||
|
||||
// The FULL key goes back, not the fingerprint: the server's equality check is exact, so
|
||||
// it can refuse if the entry was re-keyed again since this dialog rendered.
|
||||
await waitFor(() =>
|
||||
expect(body).toEqual({ version: '2.0.0', publicKey: 'NEWKEYbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' }),
|
||||
)
|
||||
// No follow-up /update: a re-pin that waited for a second call would leave the plugin
|
||||
// pinned to a key no install had ever verified against if that call never came.
|
||||
})
|
||||
})
|
||||
|
||||
describe('AdminPluginsPanel — update consent', () => {
|
||||
it('FE-COMP-PLUGINS-SIG-011: says an unsigned update is untied to its author, and still activates in one click', async () => {
|
||||
let activated = false
|
||||
mockPanel(plugin({ source_repo: 'acme/gotify', signed: false }), registryEntry({ signed: false }))
|
||||
server.use(
|
||||
http.post('*/api/admin/plugins/trek-gotify/update', () =>
|
||||
HttpResponse.json({ version: '2.0.0', activated: false, newPermissions: ['db:read:trips'], newEgress: [] }),
|
||||
),
|
||||
http.post('*/api/admin/plugins/trek-gotify/activate', () => { activated = true; return HttpResponse.json({ status: 'active' }) }),
|
||||
)
|
||||
render(<AdminPluginsPanel />)
|
||||
await screen.findByText('Gotify')
|
||||
fireEvent.click(await screen.findByRole('button', { name: /update to|2\.0\.0/i }))
|
||||
|
||||
// Informs — it does not block. No checkbox, no second click.
|
||||
expect(await screen.findByText(/nothing ties this version to its author/i)).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: /approve & turn on/i }))
|
||||
await waitFor(() => expect(activated).toBe(true))
|
||||
})
|
||||
|
||||
// The warning used to be read ONLY off the registry entry, so an unreachable registry left
|
||||
// it undefined and the pill silently vanished — at the exact moment the admin was widening
|
||||
// what unsigned code may do. The installed row carries an authoritative `signed` from the
|
||||
// server on every list call; degrade to that rather than to silence.
|
||||
//
|
||||
// Consent is reached here by turning a plugin back ON after an update widened its
|
||||
// permissions (409 CONSENT_REQUIRED) — which is the path that still works with the registry
|
||||
// down, precisely because it needs nothing from the registry.
|
||||
it('FE-COMP-PLUGINS-SIG-015: the unsigned warning survives an unreachable registry', async () => {
|
||||
server.use(
|
||||
http.get('*/api/admin/plugins', () =>
|
||||
HttpResponse.json({
|
||||
enabled: true, devLink: false,
|
||||
plugins: [plugin({ source_repo: 'acme/gotify', signed: false, enabled: 0, status: 'inactive', operatorEgress: false })],
|
||||
})),
|
||||
// The registry is down: `regById` stays empty, so the entry's `signed` is unknowable.
|
||||
http.get('*/api/admin/plugins/registry', () => HttpResponse.json({ error: 'registry unreachable' }, { status: 500 })),
|
||||
http.post('*/api/admin/plugins/trek-gotify/activate', () =>
|
||||
HttpResponse.json({ error: 'consent required', code: 'CONSENT_REQUIRED', newPermissions: ['db:read:trips'], newEgress: [] }, { status: 409 })),
|
||||
)
|
||||
render(<AdminPluginsPanel />)
|
||||
fireEvent.click(await screen.findByRole('button', { name: /enable plugin/i }))
|
||||
|
||||
// Falls back to the installed row's `signed: false` rather than going quiet.
|
||||
expect(await screen.findByText(/nothing ties this version to its author/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* A signature refusal must reach the dialog even when the plugin has NO installed row —
|
||||
* which is every fresh install from Discover, and every dependency being downloaded.
|
||||
*
|
||||
* Routing the refusal off the installed list meant those two paths silently fell back to a
|
||||
* generic toast: the admin met SIGNATURE_INVALID for the first time on the one path where the
|
||||
* dialog explaining it never opened. A fresh install has no pinned key, so it can only ever
|
||||
* be _INVALID / _INCOMPLETE — never a rotation — and both are non-overridable, so the dialog
|
||||
* must explain and offer nothing.
|
||||
*/
|
||||
describe('AdminPluginsPanel — a refusal with no installed row', () => {
|
||||
it('FE-COMP-PLUGINS-SIG-013: a fresh install refused for an INVALID signature opens the dialog, not a toast', async () => {
|
||||
server.use(
|
||||
http.get('*/api/admin/plugins', () => HttpResponse.json({ enabled: true, devLink: false, plugins: [] })),
|
||||
http.get('*/api/admin/plugins/registry', () => HttpResponse.json([registryEntry()])),
|
||||
http.post('*/api/admin/plugins/install', () =>
|
||||
HttpResponse.json({ error: 'author signature verification failed', code: 'SIGNATURE_INVALID' }, { status: 400 })),
|
||||
)
|
||||
render(<AdminPluginsPanel />)
|
||||
fireEvent.click(await screen.findByRole('button', { name: /discover/i }))
|
||||
fireEvent.click(await screen.findByRole('button', { name: /^install$/i }))
|
||||
|
||||
// The dialog, named after the plugin — which it can only know from the REGISTRY entry,
|
||||
// there being no installed row to read a name off.
|
||||
expect(await screen.findByText(/gotify's signature could not be verified/i)).toBeInTheDocument()
|
||||
await screen.findByText(/do not match the author's signature/i)
|
||||
// Non-overridable, and no key comparison — showing one would imply a choice exists.
|
||||
expect(screen.queryByRole('button', { name: /trust the new key/i })).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(/key it is offering now/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-SIG-014: a refusal while downloading a DEPENDENCY opens the dialog too', async () => {
|
||||
const parent = plugin({ id: 'trek-parent', name: 'Parent', source_repo: 'acme/parent', enabled: 0, status: 'inactive', operatorEgress: false })
|
||||
server.use(
|
||||
http.get('*/api/admin/plugins', () => HttpResponse.json({ enabled: true, devLink: false, plugins: [parent] })),
|
||||
http.get('*/api/admin/plugins/registry', () => HttpResponse.json([registryEntry()])),
|
||||
// Turning it on reveals the missing dependency…
|
||||
http.post('*/api/admin/plugins/trek-parent/activate', () =>
|
||||
HttpResponse.json({ error: 'missing dependency', code: 'DEPENDENCY_MISSING', missing: [{ id: 'trek-gotify', version: '^1.0.0' }], versionMismatch: [] }, { status: 409 })),
|
||||
// …and downloading it is refused on its signature.
|
||||
http.post('*/api/admin/plugins/install', () =>
|
||||
HttpResponse.json({ error: 'author signature verification failed', code: 'SIGNATURE_INVALID' }, { status: 400 })),
|
||||
)
|
||||
render(<AdminPluginsPanel />)
|
||||
fireEvent.click(await screen.findByRole('button', { name: /enable plugin/i }))
|
||||
fireEvent.click(await screen.findByRole('button', { name: /download/i }))
|
||||
|
||||
// Named after the DEPENDENCY, not the parent — it is the dependency's author whose
|
||||
// signature did not verify, and saying "Parent" here would point the admin at the wrong
|
||||
// plugin entirely.
|
||||
expect(await screen.findByText(/gotify's signature could not be verified/i)).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /trust the new key/i })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('AdminPluginsPanel — a block never outlives the registry relationship', () => {
|
||||
// The server clears the block on sideload/dev-link. This is the belt: even if a stale
|
||||
// block somehow reached the client, a plugin whose code the admin supplied by hand must
|
||||
// never claim an update was blocked over an author signing key.
|
||||
it('FE-COMP-PLUGINS-SIG-012: a sideloaded plugin never shows an update block', async () => {
|
||||
mockPanel(plugin({
|
||||
source_repo: 'local:upload', signed: false,
|
||||
updateBlock: { code: 'SIGNATURE_KEY_CHANGED', detail: 'the signing key changed', version: '2.0.0' },
|
||||
}))
|
||||
render(<AdminPluginsPanel />)
|
||||
await screen.findByText('Sideloaded')
|
||||
expect(screen.queryByText(/update blocked/i)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* TREK-version compatibility. The SERVER owns the semver — a second implementation in the
|
||||
* browser would eventually disagree with the install gate and offer a button that 400s —
|
||||
* so the panel only renders the verdict the API hands it (`compatible`, `latestCompatible`).
|
||||
*/
|
||||
describe('AdminPluginsPanel — TREK-version compatibility', () => {
|
||||
/** Discover cards for a plugin that is NOT installed — an installed one just reads "Installed". */
|
||||
async function openDiscover(entry: Record<string, unknown>) {
|
||||
mockPanel(plugin({ id: 'something-else' }), registryEntry(entry))
|
||||
render(<AdminPluginsPanel />)
|
||||
fireEvent.click(await screen.findByText('Discover'))
|
||||
}
|
||||
|
||||
it('blocks Install when no published version runs on this TREK, and says why', async () => {
|
||||
await openDiscover({ trek: '>=4.0.0', hostVersion: '3.3.0', compatible: false, latestCompatible: null })
|
||||
const btn = await screen.findByRole('button', { name: /^incompatible$/i })
|
||||
expect(btn).toBeDisabled()
|
||||
})
|
||||
|
||||
it('offers the newest version that DOES run here rather than a dead button', async () => {
|
||||
await openDiscover({ latest: '2.0.0', trek: '>=3.4.0', hostVersion: '3.3.0', compatible: false, latestCompatible: '1.5.0' })
|
||||
const btn = await screen.findByRole('button', { name: /^install 1\.5\.0$/i })
|
||||
expect(btn).toBeEnabled()
|
||||
})
|
||||
|
||||
it('installs normally when the latest version fits', async () => {
|
||||
await openDiscover({ trek: '>=3.2.0 <4.0.0', hostVersion: '3.3.0', compatible: true, latestCompatible: '2.0.0' })
|
||||
expect(await screen.findByRole('button', { name: /^install$/i })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('an installed plugin the server has outgrown shows the blocker on its card', async () => {
|
||||
// Same amber chip machinery as a disabled addon / missing dependency — the admin sees
|
||||
// one "here is why this cannot turn on" surface, not a new concept per blocker.
|
||||
mockPanel(plugin({
|
||||
dependencyStatus: 'hostIncompatible', trekRange: '>=3.2.0 <4.0.0', hostVersion: '4.0.0', enabled: 0, status: 'inactive',
|
||||
}))
|
||||
render(<AdminPluginsPanel />)
|
||||
expect(await screen.findByText(/needs trek >=3\.2\.0 <4\.0\.0/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useEffect, useMemo, useRef, useState, type DragEvent } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState, type CSSProperties, type DragEvent } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import {
|
||||
Blocks, AlertTriangle, PackageOpen, RefreshCw, Trash2, Download, Bug, X, ShieldCheck, UploadCloud,
|
||||
ArrowUpCircle, Github, ExternalLink, ChevronDown, Check, Lock, Search, Link2,
|
||||
ArrowUpCircle, Github, ExternalLink, ChevronDown, Check, Lock, Search, Link2, KeyRound, ShieldAlert,
|
||||
SlidersHorizontal, ArrowUpDown, CircleDot, MoreHorizontal, RotateCw, ArrowRight, Database, Users, LayoutDashboard,
|
||||
Radio, Luggage, Plane, Globe, Image, CalendarDays, Map, Bell, Cloud, Camera, Compass,
|
||||
BookOpen, Wallet, Puzzle, MapPin, ListChecks, Pencil, Tag, FileText,
|
||||
@@ -24,7 +25,7 @@ import ToggleSwitch from '../Settings/ToggleSwitch'
|
||||
|
||||
interface PluginDep { id: string; version: string }
|
||||
interface VersionMismatch { id: string; wanted: string; installed: string }
|
||||
type DependencyStatus = 'ok' | 'addonDisabled' | 'missingPlugin'
|
||||
type DependencyStatus = 'ok' | 'addonDisabled' | 'missingPlugin' | 'hostIncompatible'
|
||||
interface PluginDependencies { requiredAddons: string[]; pluginDependencies: PluginDep[] }
|
||||
interface DependencyIssues { disabledAddons: string[]; missing: PluginDep[]; versionMismatch: VersionMismatch[] }
|
||||
|
||||
@@ -49,6 +50,17 @@ interface PluginRow {
|
||||
dependencies?: PluginDependencies
|
||||
dependencyStatus?: DependencyStatus
|
||||
dependencyIssues?: DependencyIssues
|
||||
/** The TREK versions the plugin says it supports; null if it never declared any. */
|
||||
trekRange?: string | null
|
||||
/** The TREK this server is running — the server does the semver, the client just shows it. */
|
||||
hostVersion?: string
|
||||
/** The author's signature was verified and their key pinned at install. False means the
|
||||
* bytes matched the registry's sha256 and nothing more — one fewer guarantee. */
|
||||
signed?: boolean
|
||||
/** Short form of the pinned key, for eyeballing against what the author reads out. */
|
||||
keyFingerprint?: string | null
|
||||
/** Why an update was refused, if one was. `version` is the version that was refused. */
|
||||
updateBlock?: { code: string; detail: string | null; version: string | null } | null
|
||||
}
|
||||
interface RegistryItem {
|
||||
id: string
|
||||
@@ -60,11 +72,22 @@ interface RegistryItem {
|
||||
type: string
|
||||
latest: string | null
|
||||
minTrekVersion: string | null
|
||||
/** The latest version's declared TREK range; null on a legacy registry entry. */
|
||||
trek?: string | null
|
||||
hostVersion?: string
|
||||
/** Whether the LATEST version can be installed on this TREK. Computed server-side. */
|
||||
compatible?: boolean
|
||||
/** Newest installable version — the latest, an older fallback, or null if none fits. */
|
||||
latestCompatible?: string | null
|
||||
reviewedAt: string | null
|
||||
downloadCount?: number | null
|
||||
screenshotUrl: string | null
|
||||
requiredAddons?: string[]
|
||||
pluginDependencies?: PluginDep[]
|
||||
/** The latest version ships an author signature and the entry declares a key. */
|
||||
signed?: boolean
|
||||
/** The full key — public, and carried in full because re-trust compares it exactly. */
|
||||
authorPublicKey?: string | null
|
||||
}
|
||||
interface RegistryDetail extends RegistryItem {
|
||||
size: number | null
|
||||
@@ -90,6 +113,49 @@ interface ActivateErr {
|
||||
}
|
||||
}
|
||||
|
||||
/** The server's error envelope. Reading `code` — not the message text — is what lets the
|
||||
* UI tell a rotated key (overridable) from a signature that doesn't verify (never). */
|
||||
function errBody(e: unknown): { error?: string; code?: string } {
|
||||
return (e as { response?: { data?: { error?: string; code?: string } } })?.response?.data ?? {}
|
||||
}
|
||||
|
||||
/** The ONE signature refusal an admin may override, because a rotation has a benign
|
||||
* explanation. SIGNATURE_INVALID / _MISSING / _INCOMPLETE mean the bytes are not what the
|
||||
* author signed — those get an explanation and no override button at all. */
|
||||
const RETRUSTABLE = 'SIGNATURE_KEY_CHANGED'
|
||||
const SIGNATURE_CODES = [RETRUSTABLE, 'SIGNATURE_INVALID', 'SIGNATURE_MISSING', 'SIGNATURE_INCOMPLETE']
|
||||
|
||||
/**
|
||||
* What the signature dialog needs to talk about a plugin — deliberately NOT a PluginRow.
|
||||
*
|
||||
* A refusal can land on a plugin that is not installed (a fresh install from Discover, or a
|
||||
* dependency being downloaded), and those have no row. Keying the dialog off PluginRow meant
|
||||
* the lookup missed and the refusal silently degraded to a toast — on the very path where an
|
||||
* admin most often meets these codes for the first time. Name + pinned fingerprint is all the
|
||||
* dialog ever reads, and both a row and a registry entry can supply that.
|
||||
*/
|
||||
type SigSubject = { id: string; name: string; keyFingerprint: string | null }
|
||||
|
||||
/**
|
||||
* Is a recorded update block still describing the version on offer?
|
||||
*
|
||||
* Once the registry offers something NEWER than the version that was refused, the block
|
||||
* describes an artifact nobody is being offered anymore — so it reads as stale and the
|
||||
* admin can simply re-attempt (the next install re-verifies and either succeeds or
|
||||
* re-blocks with fresh values). When the registry is unreachable we can't prove staleness,
|
||||
* so the block stands: silently dropping the last thing we knew would be the worse failure.
|
||||
*/
|
||||
function blockIsCurrent(p: PluginRow, latestVer?: string): boolean {
|
||||
if (!p.updateBlock) return false
|
||||
// A block describes a refused REGISTRY update. Once a plugin is sideloaded or dev-linked
|
||||
// it has left the registry trust model — the running code is whatever the admin supplied,
|
||||
// and a block about an author signing key says nothing about it. The server clears the
|
||||
// block on both paths; this makes it impossible to render a stale one regardless.
|
||||
if (!isRegistrySourced(p.source_repo)) return false
|
||||
if (!latestVer || !p.updateBlock.version) return true
|
||||
return latestVer === p.updateBlock.version
|
||||
}
|
||||
|
||||
type T = (k: string, p?: Record<string, unknown>) => string
|
||||
type TypeFilter = 'all' | 'widget' | 'page' | 'integration' | 'trip-page'
|
||||
type StatusFilter = 'all' | 'on' | 'off' | 'update' | 'err'
|
||||
@@ -186,10 +252,21 @@ function deriveCaps(perms: string[], caps: { widget?: { slot?: string }; tripPag
|
||||
interface DepChip { icon: React.ComponentType<{ size?: number; className?: string }>; label: string; blocked: boolean }
|
||||
|
||||
// A plugin's declared dependencies as chips — a required addon (amber when that
|
||||
// addon is disabled) or a plugin dependency (amber when missing / version-mismatched).
|
||||
// addon is disabled), a plugin dependency (amber when missing / version-mismatched),
|
||||
// or the TREK version itself (amber when this server has outgrown the plugin's range,
|
||||
// which is the one blocker the admin cannot fix by flipping something else on).
|
||||
function deriveDeps(p: PluginRow, t: T): DepChip[] {
|
||||
const out: DepChip[] = []
|
||||
const issues = p.dependencyIssues
|
||||
if (p.dependencyStatus === 'hostIncompatible') {
|
||||
out.push({
|
||||
icon: AlertTriangle,
|
||||
label: p.trekRange
|
||||
? t('admin.plugins.dep.trekIncompatible', { range: p.trekRange, host: p.hostVersion ?? '?' })
|
||||
: t('admin.plugins.dep.trekUnknown'),
|
||||
blocked: true,
|
||||
})
|
||||
}
|
||||
for (const a of p.dependencies?.requiredAddons ?? []) {
|
||||
out.push({ icon: Blocks, label: t('admin.plugins.cap.requiresAddon', { addon: a }), blocked: !!issues?.disabledAddons.includes(a) })
|
||||
}
|
||||
@@ -200,6 +277,26 @@ function deriveDeps(p: PluginRow, t: T): DepChip[] {
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* What the Install button may do for a registry item.
|
||||
*
|
||||
* The server already decided compatibility — it owns the semver, and a second
|
||||
* implementation here would eventually disagree with the install gate and offer a button
|
||||
* that 400s. This only picks the wording. Note the middle case: when the newest release
|
||||
* has outrun this TREK but an older one still fits, offer THAT version rather than a dead
|
||||
* grey button. The plugin is perfectly usable, just not at its newest.
|
||||
*/
|
||||
function installOffer(item: RegistryItem, t: T): { blocked: boolean; version?: string; label: string; title?: string } {
|
||||
if (item.compatible !== false) return { blocked: false, label: t('admin.plugins.install') }
|
||||
const title = item.trek
|
||||
? t('admin.plugins.dep.trekIncompatible', { range: item.trek, host: item.hostVersion ?? '?' })
|
||||
: t('admin.plugins.dep.trekUnknown')
|
||||
if (item.latestCompatible) {
|
||||
return { blocked: false, version: item.latestCompatible, label: t('admin.plugins.installCompatible', { version: item.latestCompatible }), title }
|
||||
}
|
||||
return { blocked: true, label: t('admin.plugins.incompatible'), title }
|
||||
}
|
||||
|
||||
function PluginIcon({ name, size = 20, className }: { name: string | null; size?: number; className?: string }) {
|
||||
const Icon = (name && Object.prototype.hasOwnProperty.call(ICON_MAP, name) && ICON_MAP[name]) || Blocks
|
||||
return <Icon size={size} className={className} />
|
||||
@@ -224,6 +321,46 @@ function SideloadedBadge({ t }: { t: T }) {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a plugin came from the REGISTRY (as opposed to a manual upload or a dev-link).
|
||||
*
|
||||
* This is the precedence rule for the trust badges, and it is load-bearing: `signed`
|
||||
* derives from the pinned author key while sideloaded/dev-linked derive from source_repo,
|
||||
* so the states are NOT mutually exclusive in the data. A sideloaded plugin genuinely has
|
||||
* no pinned key, and a naive render would put "Unsigned" *and* "Sideloaded" side by side.
|
||||
* The source badge wins — it already says something strictly stronger, and doubling up
|
||||
* dilutes the amber into wallpaper, which is exactly what makes a warning worthless.
|
||||
*/
|
||||
function isRegistrySourced(sourceRepo: string | null | undefined): boolean {
|
||||
return !!sourceRepo && sourceRepo !== 'local:upload' && sourceRepo !== 'local:link'
|
||||
}
|
||||
|
||||
/**
|
||||
* Signed / Unsigned, for registry plugins only (see isRegistrySourced).
|
||||
*
|
||||
* Signed is a quiet neutral tick, NOT a green celebration; unsigned is an amber note, NOT
|
||||
* a red alarm. Roughly two thirds of the live registry is unsigned, so an alarming
|
||||
* treatment would fire on most of the catalog and teach admins to ignore it — and the
|
||||
* honest delta is small: sha256 proves the bytes are what the REGISTRY vouches for, a
|
||||
* signature proves they came from the AUTHOR. Unsigned is one fewer guarantee, not "unsafe".
|
||||
*/
|
||||
function TrustBadge({ signed, t }: { signed: boolean; t: T }) {
|
||||
if (signed) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-1.5 py-[2px] rounded-md text-[11px] font-medium bg-surface-tertiary text-content-muted border border-edge-secondary"
|
||||
title={t('admin.plugins.signedHint')}>
|
||||
<KeyRound size={11} /> {t('admin.plugins.signed')}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-1.5 py-[2px] rounded-md text-[11px] font-medium bg-warning-soft text-warning border border-warning/25"
|
||||
title={t('admin.plugins.unsignedHint')}>
|
||||
<ShieldAlert size={11} /> {t('admin.plugins.unsigned')}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/** Marks a dev-linked plugin: loaded from a local build dir + hot-reloaded (dev only). */
|
||||
function DevLinkBadge({ t }: { t: T }) {
|
||||
return (
|
||||
@@ -255,6 +392,13 @@ export default function AdminPluginsPanel() {
|
||||
const [view, setView] = useState<'installed' | 'discover'>('installed')
|
||||
const [registry, setRegistry] = useState<RegistryItem[] | null>(null)
|
||||
const [latest, setLatest] = useState<Record<string, string>>({})
|
||||
// The registry entry per id — the re-trust dialog needs the NEW author key from it (in
|
||||
// full: the server's equality check is exact), and the Discover/consent copy needs `signed`.
|
||||
const [regById, setRegById] = useState<Record<string, RegistryItem>>({})
|
||||
// Open when a signature check refused an install/update. Carries the code, so the dialog
|
||||
// knows whether an override may even be offered.
|
||||
const [signatureBlock, setSignatureBlock] = useState<{ subject: SigSubject; code: string; detail: string | null } | null>(null)
|
||||
const [retrusting, setRetrusting] = useState(false)
|
||||
const [detailFor, setDetailFor] = useState<RegistryItem | null>(null)
|
||||
const [errorsFor, setErrorsFor] = useState<{ id: string; rows: Array<{ ts: string; level: string; message: string }> } | null>(null)
|
||||
const [egressFor, setEgressFor] = useState<{ id: string; supported: boolean; hosts: string[] } | null>(null)
|
||||
@@ -288,6 +432,16 @@ export default function AdminPluginsPanel() {
|
||||
const uploadInputRef = useRef<HTMLInputElement>(null)
|
||||
const dragDepth = useRef(0)
|
||||
|
||||
// Index the registry once per fetch: the version map the update badges read, plus the
|
||||
// whole entry (author key + signed flag) the trust badges and re-trust dialog need.
|
||||
const indexRegistry = (items: RegistryItem[]) => {
|
||||
const vers: Record<string, string> = {}
|
||||
const byId: Record<string, RegistryItem> = {}
|
||||
items.forEach((i) => { byId[i.id] = i; if (i.latest) vers[i.id] = i.latest })
|
||||
setLatest(vers)
|
||||
setRegById(byId)
|
||||
}
|
||||
|
||||
const refresh = () => {
|
||||
// Keep the app-wide active-plugin store in sync so widget/hero/tab consumers
|
||||
// (e.g. the dashboard) reflect an activate/deactivate without a full reload (F5).
|
||||
@@ -298,13 +452,7 @@ export default function AdminPluginsPanel() {
|
||||
setDevLink(!!d.devLink)
|
||||
setPlugins(d.plugins || [])
|
||||
if ((d.plugins || []).length) {
|
||||
adminApi.pluginBrowse()
|
||||
.then((items: RegistryItem[]) => {
|
||||
const map: Record<string, string> = {}
|
||||
items.forEach((i) => { if (i.latest) map[i.id] = i.latest })
|
||||
setLatest(map)
|
||||
})
|
||||
.catch(() => {})
|
||||
adminApi.pluginBrowse().then(indexRegistry).catch(() => {})
|
||||
}
|
||||
})
|
||||
.catch(() => setError(true))
|
||||
@@ -312,16 +460,43 @@ export default function AdminPluginsPanel() {
|
||||
}
|
||||
useEffect(refresh, [])
|
||||
|
||||
// A signature refusal is not an ordinary "action failed": it is the one class of error
|
||||
// where WHICH failure it was decides what the admin may do about it. Route it to the
|
||||
// dialog off the CODE, never off the message text. Returns true when handled.
|
||||
//
|
||||
// The subject is resolved from wherever it can be — the installed row, else the registry
|
||||
// entry (a plugin being INSTALLED has no row yet), else the bare id. A signature code
|
||||
// ALWAYS opens the dialog: falling back to a toast is what used to hide an invalid
|
||||
// signature on a fresh install behind the same treatment as a network blip.
|
||||
const routeSignatureError = (id: string, code?: string, detail?: string): boolean => {
|
||||
if (!code || !SIGNATURE_CODES.includes(code)) return false
|
||||
const row = plugins.find(p => p.id === id)
|
||||
const subject: SigSubject = row
|
||||
? { id, name: row.name, keyFingerprint: row.keyFingerprint ?? null }
|
||||
// Not installed: no pinned key exists, so SIGNATURE_KEY_CHANGED cannot arise and the
|
||||
// dialog shows the no-override explanation — which is exactly right.
|
||||
: { id, name: regById[id]?.name ?? id, keyFingerprint: null }
|
||||
setSignatureBlock({ subject, code, detail: detail ?? null })
|
||||
return true
|
||||
}
|
||||
|
||||
const act = async (id: string, fn: () => Promise<unknown>, ok: string) => {
|
||||
setBusy(id); setMenu(null)
|
||||
try { await fn(); toast.success(ok) }
|
||||
catch (e) { toast.error((e as { response?: { data?: { error?: string } } })?.response?.data?.error || t('admin.plugins.actionError')) }
|
||||
catch (e) {
|
||||
const { error, code } = errBody(e)
|
||||
if (!routeSignatureError(id, code, error)) toast.error(error || t('admin.plugins.actionError'))
|
||||
}
|
||||
finally { setBusy(null); refresh() }
|
||||
}
|
||||
|
||||
const openDiscover = () => {
|
||||
setView('discover')
|
||||
if (!registry) adminApi.pluginBrowse().then(setRegistry).catch(() => setRegistry([]))
|
||||
if (!registry) {
|
||||
adminApi.pluginBrowse()
|
||||
.then((items: RegistryItem[]) => { setRegistry(items); indexRegistry(items) })
|
||||
.catch(() => setRegistry([]))
|
||||
}
|
||||
}
|
||||
// The rescan/reload button rediscovers locally-installed plugins AND force-pulls
|
||||
// the remote registry (bypassing the 30-min server cache + GitHub's CDN), so a
|
||||
@@ -330,9 +505,7 @@ export default function AdminPluginsPanel() {
|
||||
await adminApi.pluginRescan()
|
||||
const items: RegistryItem[] = await adminApi.pluginBrowse(true)
|
||||
setRegistry(items)
|
||||
const map: Record<string, string> = {}
|
||||
items.forEach((i) => { if (i.latest) map[i.id] = i.latest })
|
||||
setLatest(map)
|
||||
indexRegistry(items)
|
||||
}, t('admin.plugins.rescanned'))
|
||||
|
||||
// Sideload a plugin archive (installs INACTIVE — the admin still consents on activation).
|
||||
@@ -393,7 +566,7 @@ export default function AdminPluginsPanel() {
|
||||
}
|
||||
|
||||
const updateAvailable = (p: PluginRow) => !!(p.version && latest[p.id] && isNewer(latest[p.id], p.version))
|
||||
const install = (id: string) => act(id, () => adminApi.pluginInstall(id), t('admin.plugins.installed'))
|
||||
const install = (id: string, version?: string) => act(id, () => adminApi.pluginInstall(id, version ? { version } : undefined), t('admin.plugins.installed'))
|
||||
const restart = (id: string) => act(id, async () => { await adminApi.pluginDeactivate(id); await adminApi.pluginActivate(id) }, t('admin.plugins.restarted'))
|
||||
// Dev-link: register a plugin from a local built directory (dev only). Reuses the
|
||||
// same busy/toast/refresh loop as uploadPlugin; the server gates it.
|
||||
@@ -470,7 +643,13 @@ export default function AdminPluginsPanel() {
|
||||
if (r?.requiredAddons?.length) toast.error(t('admin.plugins.dep.addonDisabledToast', { addons: r.requiredAddons.join(', ') }))
|
||||
return attemptActivate(parent)
|
||||
})
|
||||
.catch((e: ActivateErr) => onActivateError(parent, e))
|
||||
// The DEPENDENCY is what's being downloaded, so a signature refusal here is about the
|
||||
// dependency's author, not the parent's — route it under depId before falling through
|
||||
// to the activation-error handling, which knows nothing about signature codes.
|
||||
.catch((e: ActivateErr) => {
|
||||
const { error, code } = errBody(e)
|
||||
if (!routeSignatureError(depId, code, error)) onActivateError(parent, e)
|
||||
})
|
||||
.finally(() => { setBusy(null); refresh() })
|
||||
}
|
||||
|
||||
@@ -481,10 +660,33 @@ export default function AdminPluginsPanel() {
|
||||
if (r.activated || (r.newPermissions.length === 0 && r.newEgress.length === 0)) toast.success(t('admin.plugins.updated'))
|
||||
else setConsentQueue(qq => [...qq, { plugin: p, version: r.version, newPermissions: r.newPermissions, newEgress: r.newEgress }])
|
||||
})
|
||||
.catch(e => toast.error((e as { response?: { data?: { error?: string } } })?.response?.data?.error || t('admin.plugins.actionError')))
|
||||
.catch(e => {
|
||||
const { error, code } = errBody(e)
|
||||
if (!routeSignatureError(p.id, code, error)) toast.error(error || t('admin.plugins.actionError'))
|
||||
})
|
||||
.finally(() => { setBusy(null); refresh() })
|
||||
}
|
||||
|
||||
// Confirm a key rotation: re-pin the new key AND update, in one server call. There is no
|
||||
// follow-up /update — a re-pin that waited for a second call would leave the plugin
|
||||
// pinned to a key no install had ever been verified against if that call never came.
|
||||
const confirmRetrust = (id: string, version: string, publicKey: string) => {
|
||||
setRetrusting(true)
|
||||
adminApi.pluginRetrust(id, version, publicKey)
|
||||
.then((r: { version: string; activated: boolean; newPermissions: string[]; newEgress: string[] }) => {
|
||||
setSignatureBlock(null)
|
||||
// A re-trusted update widening permissions still needs consent — re-trusting a
|
||||
// signing key says nothing about what the new code is allowed to do. Re-trust only
|
||||
// ever fires for an INSTALLED plugin, so the row is there to consent against.
|
||||
const p = plugins.find(x => x.id === id)
|
||||
if (p && !r.activated && (r.newPermissions.length > 0 || r.newEgress.length > 0)) {
|
||||
setConsentQueue(qq => [...qq, { plugin: p, version: r.version, newPermissions: r.newPermissions, newEgress: r.newEgress }])
|
||||
} else toast.success(t('admin.plugins.retrusted'))
|
||||
})
|
||||
.catch(e => toast.error(errBody(e).error || t('admin.plugins.actionError')))
|
||||
.finally(() => { setRetrusting(false); refresh() })
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const updatable = useMemo(() => plugins.filter(updateAvailable), [plugins, latest])
|
||||
|
||||
@@ -691,8 +893,13 @@ export default function AdminPluginsPanel() {
|
||||
) : shownInstalled.map(p => (
|
||||
<InstalledRow key={p.id} p={p} t={t} busy={busy} menu={menu} setMenu={setMenu}
|
||||
hasUpdate={updateAvailable(p)} latestVer={latest[p.id]}
|
||||
blocked={blockIsCurrent(p, latest[p.id])}
|
||||
onToggle={() => toggle(p)}
|
||||
onUpdate={() => runUpdate(p)} onRestart={() => restart(p.id)}
|
||||
onReviewBlock={() => setSignatureBlock({
|
||||
subject: { id: p.id, name: p.name, keyFingerprint: p.keyFingerprint ?? null },
|
||||
code: p.updateBlock!.code, detail: p.updateBlock!.detail,
|
||||
})}
|
||||
onErrors={() => openErrors(p.id)} onEgress={() => openEgress(p.id)}
|
||||
onUninstall={() => { setMenu(null); setConfirmUninstall(p) }} />
|
||||
))}
|
||||
@@ -791,9 +998,26 @@ export default function AdminPluginsPanel() {
|
||||
message={t('admin.plugins.uninstallBody')}
|
||||
/>
|
||||
|
||||
{signatureBlock && (
|
||||
<SignatureBlockDialog
|
||||
data={signatureBlock} entry={regById[signatureBlock.subject.id]} busy={retrusting} t={t}
|
||||
onRetrust={(version, publicKey) => confirmRetrust(signatureBlock.subject.id, version, publicKey)}
|
||||
onClose={() => setSignatureBlock(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{consentQueue[0] && (
|
||||
<UpdateConsentDialog
|
||||
data={consentQueue[0]} t={t}
|
||||
// Prefer the REGISTRY's flag — consent is about the code being installed, not the
|
||||
// code running now — but fall back to the installed row's, which the server always
|
||||
// sends. Reading only the registry meant an unreachable registry left `regById`
|
||||
// empty and the warning silently vanished at the exact moment an admin was widening
|
||||
// what unsigned code may do.
|
||||
unsigned={
|
||||
isRegistrySourced(consentQueue[0].plugin.source_repo) &&
|
||||
(regById[consentQueue[0].plugin.id]?.signed ?? consentQueue[0].plugin.signed) === false
|
||||
}
|
||||
onApprove={async () => {
|
||||
const c = consentQueue[0]; setConsentQueue(qq => qq.slice(1))
|
||||
// consent:true — the ONLY path that may widen a plugin's granted rights.
|
||||
@@ -886,14 +1110,33 @@ function EmptyState({ t, onDiscover }: { t: T; onDiscover: () => void }) {
|
||||
)
|
||||
}
|
||||
|
||||
function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, onToggle, onUpdate, onRestart, onErrors, onEgress, onUninstall }: {
|
||||
function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, blocked, onToggle, onUpdate, onRestart, onReviewBlock, onErrors, onEgress, onUninstall }: {
|
||||
p: PluginRow; t: T; busy: string | null; menu: string | null; setMenu: (v: string | null) => void
|
||||
hasUpdate: boolean; latestVer?: string
|
||||
onToggle: () => void; onUpdate: () => void; onRestart: () => void; onErrors: () => void; onEgress: () => void; onUninstall: () => void
|
||||
hasUpdate: boolean; latestVer?: string; blocked: boolean
|
||||
onToggle: () => void; onUpdate: () => void; onRestart: () => void; onReviewBlock: () => void
|
||||
onErrors: () => void; onEgress: () => void; onUninstall: () => void
|
||||
}) {
|
||||
const caps = deriveCaps(parseJson<string[]>(p.permissions, []), parseJson<{ widget?: { slot?: string } }>(p.capabilities, {}), t)
|
||||
const deps = deriveDeps(p, t)
|
||||
const menuOpen = menu === `row:${p.id}`
|
||||
const menuBtnRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
// The menu is portaled to <body> and positioned `fixed` against the ⋯ button,
|
||||
// because the row's ancestor (PageSidebar) is `overflow-hidden` and would clip
|
||||
// an in-flow `absolute` menu — losing Delete on the lower rows of a long list.
|
||||
// Re-anchor while the page moves under it.
|
||||
const [, reanchor] = useState(0)
|
||||
useEffect(() => {
|
||||
if (!menuOpen) return
|
||||
const onMove = () => reanchor(n => n + 1)
|
||||
window.addEventListener('scroll', onMove, true)
|
||||
window.addEventListener('resize', onMove)
|
||||
return () => {
|
||||
window.removeEventListener('scroll', onMove, true)
|
||||
window.removeEventListener('resize', onMove)
|
||||
}
|
||||
}, [menuOpen])
|
||||
|
||||
return (
|
||||
<div className="group relative flex items-center gap-3 sm:gap-4 px-2.5 sm:px-3 py-3.5 rounded-2xl hover:bg-surface-secondary transition-colors">
|
||||
<div className="relative shrink-0">
|
||||
@@ -911,8 +1154,24 @@ function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, onToggl
|
||||
{p.reviewed_at && <ReviewedBadge t={t} compact />}
|
||||
{p.source_repo === 'local:upload' && <SideloadedBadge t={t} />}
|
||||
{p.source_repo === 'local:link' && <DevLinkBadge t={t} />}
|
||||
{/* Registry plugins only — a sideloaded/dev-linked plugin already says something
|
||||
strictly stronger, and stacking "Unsigned" on top of it just dilutes the amber. */}
|
||||
{isRegistrySourced(p.source_repo) && <TrustBadge signed={!!p.signed} t={t} />}
|
||||
</div>
|
||||
{p.description && <p className="text-[12.5px] text-content-muted mt-0.5 truncate">{p.description}</p>}
|
||||
{/* A refused update leaves a WORKING plugin pinned at its old version — so this is
|
||||
its own row state, not an error state, and it persists until it is resolved
|
||||
rather than dying with the toast that first reported it. */}
|
||||
{blocked && p.updateBlock && (
|
||||
<div className="flex items-center gap-1.5 mt-1.5 text-[11.5px] text-warning">
|
||||
<ShieldAlert size={13} className="shrink-0" />
|
||||
<span className="truncate">{t('admin.plugins.updateBlocked', { reason: p.updateBlock.detail ?? p.updateBlock.code })}</span>
|
||||
<button onClick={onReviewBlock}
|
||||
className="shrink-0 font-semibold underline underline-offset-2 hover:opacity-80 transition-opacity">
|
||||
{t('admin.plugins.reviewBlock')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{p.status === 'error' && p.last_error ? (
|
||||
<div className="flex items-center gap-1.5 mt-1.5 text-[11.5px] text-danger">
|
||||
<AlertTriangle size={13} className="shrink-0" /><span className="truncate">{p.last_error}</span>
|
||||
@@ -970,12 +1229,13 @@ function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, onToggl
|
||||
</span>
|
||||
<ToggleSwitch on={p.enabled === 1} label={t('admin.plugins.enabledToggle')} onToggle={onToggle} />
|
||||
<div className="relative">
|
||||
<button onClick={() => setMenu(menuOpen ? null : `row:${p.id}`)}
|
||||
<button ref={menuBtnRef} data-testid={`plugin-row-menu-btn-${p.id}`} onClick={() => setMenu(menuOpen ? null : `row:${p.id}`)}
|
||||
className="w-[34px] h-[34px] grid place-items-center rounded-lg text-content-faint hover:text-content hover:bg-surface-tertiary transition-colors">
|
||||
<MoreHorizontal size={17} />
|
||||
</button>
|
||||
{menuOpen && (
|
||||
<div className="absolute top-10 right-0 z-30 min-w-[180px] p-1.5 rounded-xl border border-edge bg-surface-card shadow-elevated">
|
||||
{menuOpen && createPortal(
|
||||
<div data-testid={`plugin-row-menu-${p.id}`} style={anchorMenu(menuBtnRef.current)}
|
||||
className="min-w-[180px] p-1.5 rounded-xl border border-edge bg-surface-card shadow-elevated">
|
||||
{p.enabled === 1 && (
|
||||
<MenuItem icon={<RotateCw size={14} />} label={t('admin.plugins.restart')} onClick={onRestart} />
|
||||
)}
|
||||
@@ -995,7 +1255,8 @@ function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, onToggl
|
||||
)}
|
||||
<div className="my-1 border-t border-edge-secondary" />
|
||||
<MenuItem icon={<Trash2 size={14} />} label={t('common.delete')} danger onClick={onUninstall} />
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1003,6 +1264,23 @@ function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, onToggl
|
||||
)
|
||||
}
|
||||
|
||||
/** Tallest the ⋯ menu gets (6 items + divider + padding). Drives the flip. */
|
||||
const ROW_MENU_MAX_H = 260
|
||||
|
||||
/** Pin a portaled menu to the right edge of its trigger, flipping up when the bottom is tight. */
|
||||
function anchorMenu(trigger: HTMLElement | null): CSSProperties {
|
||||
const r = trigger?.getBoundingClientRect()
|
||||
if (!r) return { position: 'fixed', top: 0, right: 0, zIndex: 100 }
|
||||
const spaceBelow = window.innerHeight - r.bottom
|
||||
const openUp = spaceBelow < ROW_MENU_MAX_H && r.top > spaceBelow
|
||||
return {
|
||||
position: 'fixed',
|
||||
right: Math.max(8, window.innerWidth - r.right),
|
||||
...(openUp ? { bottom: window.innerHeight - r.top + 4 } : { top: r.bottom + 4 }),
|
||||
zIndex: 100,
|
||||
}
|
||||
}
|
||||
|
||||
function MenuItem({ icon, label, onClick, danger }: { icon: React.ReactNode; label: string; onClick: () => void; danger?: boolean }) {
|
||||
return (
|
||||
<button onClick={onClick}
|
||||
@@ -1030,7 +1308,7 @@ function Screenshot({ url, className, iconSize = 28 }: { url: string | null; cla
|
||||
|
||||
function RegistryGrid({ items, onInstall, onOpenDetail, busy, t, installedIds, filtered }: {
|
||||
items: RegistryItem[] | null
|
||||
onInstall: (id: string) => void
|
||||
onInstall: (id: string, version?: string) => void
|
||||
onOpenDetail: (item: RegistryItem) => void
|
||||
busy: string | null
|
||||
t: T
|
||||
@@ -1048,6 +1326,7 @@ function RegistryGrid({ items, onInstall, onOpenDetail, busy, t, installedIds, f
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3.5 sm:gap-4 px-4 sm:px-6 pb-5 pt-1">
|
||||
{items.map(item => {
|
||||
const installed = installedIds.has(item.id)
|
||||
const offer = installOffer(item, t)
|
||||
return (
|
||||
<div key={item.id} role="button" tabIndex={0} onClick={() => onOpenDetail(item)}
|
||||
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onOpenDetail(item) } }}
|
||||
@@ -1068,17 +1347,21 @@ function RegistryGrid({ items, onInstall, onOpenDetail, busy, t, installedIds, f
|
||||
<span className="text-sm font-semibold tracking-[-.006em] text-content truncate">{item.name}</span>
|
||||
<span className="text-[11.5px] text-content-faint mt-0.5">{item.author}</span>
|
||||
<p className="text-xs text-content-muted mt-2 line-clamp-2 flex-1">{item.description}</p>
|
||||
<div className="flex items-center gap-2 mt-3">
|
||||
<div className="flex items-center gap-2 mt-3 flex-wrap">
|
||||
<TypeBadge type={item.type} t={t} />
|
||||
{/* Everything in Discover is registry-sourced, so the badge always applies. */}
|
||||
<TrustBadge signed={!!item.signed} t={t} />
|
||||
{item.latest && <span className="text-[10.5px] text-content-faint tabular-nums">v{item.latest}</span>}
|
||||
{typeof item.downloadCount === 'number' && item.downloadCount > 0 && (
|
||||
<span className="inline-flex items-center gap-1 text-[10.5px] text-content-faint tabular-nums" title={t('admin.plugins.downloads')}>
|
||||
<Download size={11} /> {formatCompactCount(item.downloadCount)}
|
||||
</span>
|
||||
)}
|
||||
<button onClick={e => { e.stopPropagation(); onInstall(item.id) }} disabled={busy === item.id || installed}
|
||||
<button onClick={e => { e.stopPropagation(); onInstall(item.id, offer.version) }}
|
||||
disabled={busy === item.id || installed || offer.blocked}
|
||||
title={installed ? undefined : offer.title}
|
||||
className="ml-auto text-xs font-semibold px-3.5 py-1.5 rounded-lg bg-accent text-accent-text hover:bg-accent-hover disabled:opacity-50 disabled:bg-surface-tertiary disabled:text-content-faint transition-colors">
|
||||
{installed ? t('admin.plugins.installed') : t('admin.plugins.install')}
|
||||
{installed ? t('admin.plugins.installed') : offer.label}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1106,7 +1389,7 @@ function PermLabel({ perm, t }: { perm: string; t: T }) {
|
||||
|
||||
function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, locale }: {
|
||||
item: RegistryItem; installed: boolean; busy: string | null
|
||||
onInstall: (id: string) => void; onClose: () => void; t: T; locale: string
|
||||
onInstall: (id: string, version?: string) => void; onClose: () => void; t: T; locale: string
|
||||
}) {
|
||||
const [detail, setDetail] = useState<RegistryDetail | null>(null)
|
||||
const [failed, setFailed] = useState(false)
|
||||
@@ -1124,6 +1407,9 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local
|
||||
const repoUrl = `https://github.com/${item.repo}`
|
||||
const homepage = item.homepage && /^https?:\/\//i.test(item.homepage) && item.homepage !== repoUrl ? item.homepage : null
|
||||
const sizeKb = detail?.size ? Math.max(1, Math.round(detail.size / 1024)) : null
|
||||
// The detail fetch carries the same compat verdict as the browse list; prefer it once
|
||||
// it lands (it is keyed to the same entry) and fall back to the grid item until then.
|
||||
const offer = installOffer(detail ?? item, t)
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={onClose}>
|
||||
@@ -1145,15 +1431,24 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local
|
||||
</div>
|
||||
<p className="text-[12.5px] text-content-faint mt-0.5">{item.author}{item.latest ? ` · v${item.latest}` : ''}</p>
|
||||
</div>
|
||||
<button onClick={() => onInstall(item.id)} disabled={busy === item.id || installed}
|
||||
<button onClick={() => onInstall(item.id, offer.version)}
|
||||
disabled={busy === item.id || installed || offer.blocked}
|
||||
title={installed ? undefined : offer.title}
|
||||
className="self-end text-[13px] font-semibold px-3 sm:px-4 py-2 rounded-lg bg-accent text-accent-text hover:bg-accent-hover disabled:opacity-50 disabled:bg-surface-tertiary disabled:text-content-faint transition-colors shrink-0">
|
||||
{installed ? t('admin.plugins.installed') : t('admin.plugins.install')}
|
||||
{installed ? t('admin.plugins.installed') : offer.label}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-4 sm:px-5 pt-4 pb-5">
|
||||
<p className="text-[13.5px] text-content-secondary leading-relaxed">{item.description}</p>
|
||||
{failed && <p className="text-xs text-danger mt-3">{t('admin.plugins.detailError')}</p>}
|
||||
{/* The reason the button is blocked (or offering an older version) — a tooltip
|
||||
alone would leave a touch user with a dead button and no explanation. */}
|
||||
{offer.title && !installed && (
|
||||
<p className="flex items-start gap-1.5 text-xs text-warning bg-warning-soft border border-warning/25 rounded-lg px-2.5 py-2 mt-3">
|
||||
<AlertTriangle size={13} className="shrink-0 mt-[1px]" /> {offer.title}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{manifest && (
|
||||
<div className="mt-5">
|
||||
@@ -1220,7 +1515,11 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-3 mt-2.5">
|
||||
{item.latest && <Meta k={t('admin.plugins.metaVersion')} v={`v${item.latest}`} />}
|
||||
{sizeKb && <Meta k={t('admin.plugins.metaSize')} v={`${sizeKb} KB`} />}
|
||||
{item.minTrekVersion && <Meta k={t('admin.plugins.metaRequires')} v={`TREK ${item.minTrekVersion}+`} />}
|
||||
{/* The range, not just its lower bound: "TREK 3.2.0+" reads as "and anything
|
||||
newer", which is exactly the claim a `<4.0.0` upper bound denies. */}
|
||||
{(item.trek || item.minTrekVersion) && (
|
||||
<Meta k={t('admin.plugins.metaRequires')} v={item.trek ? `TREK ${item.trek}` : `TREK ${item.minTrekVersion}+`} />
|
||||
)}
|
||||
{item.reviewedAt && <Meta k={t('admin.plugins.metaReviewed')} v={new Date(item.reviewedAt).toLocaleDateString(locale)} />}
|
||||
{typeof item.downloadCount === 'number' && item.downloadCount > 0 && (
|
||||
<Meta k={t('admin.plugins.downloads')} v={item.downloadCount.toLocaleString(locale)} />
|
||||
@@ -1254,8 +1553,101 @@ function Meta({ k, v }: { k: string; v: string }) {
|
||||
return <div><div className="text-[12px] text-content-faint">{k}</div><div className="text-[12.5px] font-medium text-content mt-0.5">{v}</div></div>
|
||||
}
|
||||
|
||||
function UpdateConsentDialog({ data, t, onApprove, onLater }: {
|
||||
/**
|
||||
* A signature check refused an install/update.
|
||||
*
|
||||
* The override lives here, and it is scoped to exactly one code. A rotated key has a
|
||||
* benign explanation — the author rotated it, or lost it and made a new one. A signature
|
||||
* that does NOT verify does not: it means the bytes are not what the author signed, which
|
||||
* is corruption or an attack, and there is no story where the right answer is "let the
|
||||
* admin wave it through". So SIGNATURE_INVALID / _MISSING / _INCOMPLETE get a clear
|
||||
* explanation and no override button AT ALL — not a disabled one, not one behind a
|
||||
* confirm. The absence of an escape hatch is the feature.
|
||||
*
|
||||
* (The server enforces this too — it re-derives the condition and refuses anything but a
|
||||
* changed key. This dialog is the convenience, not the control.)
|
||||
*/
|
||||
function SignatureBlockDialog({ data, entry, busy, t, onRetrust, onClose }: {
|
||||
data: { subject: SigSubject; code: string; detail: string | null }
|
||||
entry: RegistryItem | undefined
|
||||
busy: boolean; t: T
|
||||
onRetrust: (version: string, publicKey: string) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const canRetrust = data.code === RETRUSTABLE
|
||||
const newKey = entry?.authorPublicKey ?? null
|
||||
const version = entry?.latest ?? null
|
||||
// Only offer the override when we actually hold the new key + version to send: the
|
||||
// request carries both, and the server compares the key exactly.
|
||||
const offerRetrust = canRetrust && !!newKey && !!version
|
||||
|
||||
const bodyKey = data.code === RETRUSTABLE ? 'admin.plugins.sig.keyChangedBody'
|
||||
: data.code === 'SIGNATURE_MISSING' ? 'admin.plugins.sig.missingBody'
|
||||
: data.code === 'SIGNATURE_INCOMPLETE' ? 'admin.plugins.sig.incompleteBody'
|
||||
: 'admin.plugins.sig.invalidBody'
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={onClose}>
|
||||
<div className="bg-surface-card border border-edge rounded-2xl w-full max-w-md shadow-modal overflow-hidden" onClick={e => e.stopPropagation()}>
|
||||
<div className="px-5 py-4 border-b border-edge-secondary flex items-start gap-3">
|
||||
<div className="w-9 h-9 rounded-lg bg-warning-soft grid place-items-center shrink-0"><ShieldAlert size={18} className="text-warning" /></div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-content">{t('admin.plugins.sig.title', { name: data.subject.name })}</h3>
|
||||
<p className="text-xs text-content-muted mt-1">{t(bodyKey as never)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-5 space-y-4">
|
||||
{/* Fingerprints, not full keys: these exist to be COMPARED by a human — read the
|
||||
new one back to the author over the phone. The full key travels in the request. */}
|
||||
{canRetrust && (
|
||||
<div className="space-y-2">
|
||||
<KeyRow label={t('admin.plugins.sig.pinnedKey')} value={data.subject.keyFingerprint ?? '—'} />
|
||||
<KeyRow label={t('admin.plugins.sig.newKey')} value={fingerprint(newKey) ?? '—'} highlight />
|
||||
<p className="text-[11.5px] text-content-muted leading-relaxed pt-1">{t('admin.plugins.sig.confirmOutOfBand')}</p>
|
||||
</div>
|
||||
)}
|
||||
{!canRetrust && data.detail && (
|
||||
<p className="text-xs text-content-faint font-mono break-all bg-surface-tertiary rounded-lg px-3 py-2">{data.detail}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-5 py-3.5 border-t border-edge-secondary bg-surface-secondary flex items-center justify-end gap-2">
|
||||
<button onClick={onClose}
|
||||
className="text-xs font-medium px-3.5 py-2 rounded-lg border border-edge text-content-muted hover:text-content hover:bg-surface-tertiary transition-colors">
|
||||
{offerRetrust ? t('admin.plugins.sig.cancel') : t('common.close')}
|
||||
</button>
|
||||
{offerRetrust && (
|
||||
<button onClick={() => onRetrust(version, newKey)} disabled={busy}
|
||||
className="text-xs font-semibold px-4 py-2 rounded-lg bg-warning text-white hover:opacity-90 transition-opacity disabled:opacity-50">
|
||||
{t('admin.plugins.sig.retrustConfirm')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function KeyRow({ label, value, highlight }: { label: string; value: string; highlight?: boolean }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 rounded-lg border border-edge-secondary bg-surface-tertiary px-3 py-2">
|
||||
<span className="text-[11.5px] text-content-muted shrink-0">{label}</span>
|
||||
<code className={`font-mono text-[12px] break-all ${highlight ? 'text-warning font-semibold' : 'text-content'}`}>{value}</code>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Client-side twin of the server's fingerprint: head…tail of the base64 payload. Display
|
||||
* only — every equality check that matters happens server-side against the full key. */
|
||||
function fingerprint(key: string | null): string | null {
|
||||
if (!key) return null
|
||||
const payload = key.trim().split(/\r?\n/).map(l => l.trim()).filter(l => l && !l.startsWith('untrusted comment')).pop()
|
||||
if (!payload) return null
|
||||
return payload.length <= 20 ? payload : `${payload.slice(0, 8)}…${payload.slice(-8)}`
|
||||
}
|
||||
|
||||
function UpdateConsentDialog({ data, unsigned, t, onApprove, onLater }: {
|
||||
data: { plugin: PluginRow; version: string; newPermissions: string[]; newEgress: string[] }
|
||||
unsigned: boolean
|
||||
t: T; onApprove: () => void; onLater: () => void
|
||||
}) {
|
||||
return (
|
||||
@@ -1269,6 +1661,15 @@ function UpdateConsentDialog({ data, t, onApprove, onLater }: {
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-5 space-y-4">
|
||||
{/* The admin is about to widen what this code may do — so say, right here, that
|
||||
nothing ties this code to its author. One line, no checkbox, no extra click:
|
||||
this informs, it does not block. */}
|
||||
{unsigned && (
|
||||
<p className="flex items-start gap-2 text-xs text-warning bg-warning-soft border border-warning/25 rounded-lg px-3 py-2">
|
||||
<ShieldAlert size={13} className="mt-0.5 shrink-0" />
|
||||
<span>{t('admin.plugins.sig.consentUnsigned')}</span>
|
||||
</p>
|
||||
)}
|
||||
{data.newPermissions.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-content-muted">{t('admin.plugins.updateNewPermissions')}</h4>
|
||||
@@ -1358,6 +1759,7 @@ function SecurityInfo({ t }: { t: T }) {
|
||||
['admin.plugins.security.limitsTitle', 'admin.plugins.security.limitsBody'],
|
||||
['admin.plugins.security.worstTitle', 'admin.plugins.security.worstBody'],
|
||||
['admin.plugins.security.reviewedTitle', 'admin.plugins.security.reviewedBody'],
|
||||
['admin.plugins.security.signedTitle', 'admin.plugins.security.signedBody'],
|
||||
['admin.plugins.security.trustTitle', 'admin.plugins.security.trustBody'],
|
||||
]
|
||||
return (
|
||||
|
||||
@@ -222,6 +222,54 @@ describe('CostsPanel — settlements in the ledger', () => {
|
||||
expect(posted!.payers).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps "no one paid yet" when reopening a payer-less expense (#1533)', async () => {
|
||||
seedStore(useAuthStore, { user: buildUser({ id: 1, username: 'alice' }), isAuthenticated: true })
|
||||
let put: Record<string, unknown> | null = null
|
||||
const item = {
|
||||
...buildBudgetItem({ trip_id: 1, category: 'food', name: 'Hotel' }),
|
||||
id: 5,
|
||||
total_price: 120,
|
||||
payers: [],
|
||||
members: [{ user_id: 1, username: 'alice', paid: 0 }, { user_id: 2, username: 'bob', paid: 0 }],
|
||||
}
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
|
||||
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
|
||||
http.put('/api/trips/1/budget/5', async ({ request }) => {
|
||||
put = await request.json() as Record<string, unknown>
|
||||
return HttpResponse.json({ item })
|
||||
}),
|
||||
)
|
||||
const { default: userEvent } = await import('@testing-library/user-event')
|
||||
const user = userEvent.setup()
|
||||
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
||||
|
||||
await screen.findByText('Hotel')
|
||||
await user.click(screen.getByTitle('Edit'))
|
||||
|
||||
// Nobody paid this expense — reopening it must not silently reselect "You".
|
||||
expect(await screen.findByRole('button', { name: 'No one paid yet' })).toBeInTheDocument()
|
||||
|
||||
// …and saving an untouched edit must not assign the current user as payer.
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }))
|
||||
await waitFor(() => expect(put).toBeTruthy())
|
||||
expect(put!.payers).toEqual([])
|
||||
})
|
||||
|
||||
it('still defaults a brand-new expense to "You" as the payer', async () => {
|
||||
seedStore(useAuthStore, { user: buildUser({ id: 1, username: 'alice' }), isAuthenticated: true })
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })),
|
||||
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
|
||||
)
|
||||
const { default: userEvent } = await import('@testing-library/user-event')
|
||||
const user = userEvent.setup()
|
||||
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'Add expense' }))
|
||||
expect(await screen.findByRole('button', { name: 'You' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('exports the expenses as a CSV download (#1500)', async () => {
|
||||
// Display in the trip's own currency so FX conversion is an identity.
|
||||
seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: 'EUR' } })
|
||||
|
||||
@@ -989,10 +989,12 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
const [participants, setParticipants] = useState<Set<number>>(() =>
|
||||
editing ? new Set((editing.members || []).map(m => m.user_id)) : new Set(people.map(p => p.id)))
|
||||
|
||||
// Payer state: 0 represents "Nobody (planning entry)"
|
||||
// Payer state: 0 represents "Nobody (planning entry)". On an existing expense a
|
||||
// missing payer is a deliberate choice, so only a brand-new one defaults to me.
|
||||
const [payerId, setPayerId] = useState<number>(() => {
|
||||
const existingPayer = (editing?.payers || []).find(p => p.amount > 0)
|
||||
return existingPayer ? existingPayer.user_id : me
|
||||
if (existingPayer) return existingPayer.user_id
|
||||
return editing ? 0 : me
|
||||
})
|
||||
|
||||
const [splitMode, setSplitMode] = useState<'equally' | 'custom' | 'ticket'>(() => {
|
||||
|
||||
@@ -170,6 +170,18 @@ describe('downloadTripPDF', () => {
|
||||
expect(srcdoc).toContain('Colosseum')
|
||||
})
|
||||
|
||||
it('FE-COMP-TRIPPDF-005c: the gap under the day header lives in the repeated thead cell (#1531)', async () => {
|
||||
await downloadTripPDF(richArgs)
|
||||
const iframe = getIframe()
|
||||
const srcdoc = iframe!.srcdoc
|
||||
// The thead is repeated on every overflow page, so the spacing below the header bar
|
||||
// must be declared on its cell...
|
||||
expect(srcdoc).toContain('.day-header > tr > td { padding-bottom: 12px; }')
|
||||
// ...and not as a block-start padding on .day-body, which the print engine only paints
|
||||
// on the first fragment of the (fragmented) body cell.
|
||||
expect(srcdoc).toContain('.day-body { padding: 0 28px 6px; }')
|
||||
})
|
||||
|
||||
it('FE-COMP-TRIPPDF-006: escHtml prevents XSS in trip title', async () => {
|
||||
const args = {
|
||||
...minimalArgs,
|
||||
|
||||
@@ -495,7 +495,11 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
||||
.day-title { font-size: 13px; font-weight: 600; color: #fff; flex: 1; }
|
||||
.day-date { font-size: 9px; color: rgba(255,255,255,0.45); }
|
||||
.day-cost { font-size: 9px; font-weight: 600; color: rgba(255,255,255,0.65); }
|
||||
.day-body { padding: 12px 28px 6px; }
|
||||
/* The gap under the header bar must sit inside the repeated <thead> cell: a block-start
|
||||
padding on .day-body is only painted on the box's first fragment, so overflow pages
|
||||
would render their first card flush against the bar (#1531). */
|
||||
.day-header > tr > td { padding-bottom: 12px; }
|
||||
.day-body { padding: 0 28px 6px; }
|
||||
|
||||
/* accommodation info */
|
||||
.day-accommodations-overview { font-size: 12px; }
|
||||
|
||||
@@ -96,9 +96,12 @@ interface DayPlanSidebarProps {
|
||||
onScrollTopChange?: (top: number) => void
|
||||
/** Mobile: show the route tools footer (Route toggle / Optimize / travel profile) on expanded days, since selecting a day closes the sheet */
|
||||
showRouteToolsWhenExpanded?: boolean
|
||||
/** Mobile: drag & drop reorder is disabled (touch-scroll hijack, #1432); the
|
||||
* grip handle is hidden and the arrow reorder buttons take over instead. */
|
||||
isMobile?: boolean
|
||||
/** Coarse primary pointer. Drag & drop reorder is disabled here (it hijacks the
|
||||
* touch-scroll gesture, #1432); the grip handle is hidden and the arrow reorder
|
||||
* buttons take over instead. Distinct from isMobile, which is width-based — a
|
||||
* tablet is a wide touch device and needs both. */
|
||||
isTouch?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -144,7 +147,9 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
onScrollTopChange,
|
||||
showRouteToolsWhenExpanded = false,
|
||||
isMobile = false,
|
||||
isTouch = false,
|
||||
} = props
|
||||
const dragDisabled = isMobile || isTouch
|
||||
const toast = useToast()
|
||||
const { t, language, locale } = useTranslation()
|
||||
const ctxMenu = useContextMenu()
|
||||
@@ -1027,6 +1032,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
onScrollTopChange,
|
||||
showRouteToolsWhenExpanded,
|
||||
isMobile,
|
||||
dragDisabled,
|
||||
toast,
|
||||
t,
|
||||
language,
|
||||
@@ -1193,6 +1199,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
onScrollTopChange,
|
||||
showRouteToolsWhenExpanded,
|
||||
isMobile,
|
||||
dragDisabled,
|
||||
toast,
|
||||
t,
|
||||
language,
|
||||
@@ -1657,9 +1664,10 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
return (
|
||||
<React.Fragment key={`place-${assignment.id}`}>
|
||||
<div
|
||||
draggable={canEditDays && !isMobile}
|
||||
className="dp-row"
|
||||
draggable={canEditDays && !dragDisabled}
|
||||
onDragStart={e => {
|
||||
if (!canEditDays || isMobile) { e.preventDefault(); return }
|
||||
if (!canEditDays || dragDisabled) { e.preventDefault(); return }
|
||||
e.dataTransfer.setData('assignmentId', String(assignment.id))
|
||||
e.dataTransfer.setData('fromDayId', String(day.id))
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
@@ -1754,7 +1762,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
opacity: isDraggingThis ? 0.4 : 1,
|
||||
}}
|
||||
>
|
||||
{canEditDays && !isMobile && <div className="dp-grip" style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
|
||||
{canEditDays && !dragDisabled && <div className="dp-grip" style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
|
||||
<GripVertical size={13} strokeWidth={1.8} />
|
||||
</div>}
|
||||
<div
|
||||
@@ -2014,9 +2022,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
const key = inBottom ? `transport-after-${res.id}${ls}-${day.id}` : `transport-${res.id}${ls}-${day.id}`
|
||||
if (dropTargetRef.current !== key) setDropTargetKey(key)
|
||||
}}
|
||||
draggable={canEditDays && spanPhase !== 'middle' && !res.__leg && !isMobile}
|
||||
draggable={canEditDays && spanPhase !== 'middle' && !res.__leg && !dragDisabled}
|
||||
onDragStart={e => {
|
||||
if (!canEditDays || spanPhase === 'middle' || res.__leg || isMobile) { e.preventDefault(); return }
|
||||
if (!canEditDays || spanPhase === 'middle' || res.__leg || dragDisabled) { e.preventDefault(); return }
|
||||
// setData is required for the drag to start reliably (Firefox) and
|
||||
// matches how place/note items initiate their drag.
|
||||
e.dataTransfer.setData('reservationId', String(res.id))
|
||||
@@ -2065,7 +2073,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
opacity: draggingId === res.id ? 0.4 : spanPhase === 'middle' ? 0.65 : 1,
|
||||
}}
|
||||
>
|
||||
{canEditDays && spanPhase !== 'middle' && !res.__leg && !isMobile && (
|
||||
{canEditDays && spanPhase !== 'middle' && !res.__leg && !dragDisabled && (
|
||||
<div className="dp-grip" style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
|
||||
<GripVertical size={13} strokeWidth={1.8} />
|
||||
</div>
|
||||
@@ -2182,8 +2190,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
return (
|
||||
<React.Fragment key={`note-${note.id}`}>
|
||||
<div
|
||||
draggable={canEditDays && !isMobile}
|
||||
onDragStart={e => { if (!canEditDays || isMobile) { e.preventDefault(); return } e.dataTransfer.setData('noteId', String(note.id)); e.dataTransfer.setData('fromDayId', String(day.id)); e.dataTransfer.effectAllowed = 'move'; dragDataRef.current = { noteId: String(note.id), fromDayId: String(day.id) }; setDraggingId(`note-${note.id}`) }}
|
||||
className="dp-row"
|
||||
draggable={canEditDays && !dragDisabled}
|
||||
onDragStart={e => { if (!canEditDays || dragDisabled) { e.preventDefault(); return } e.dataTransfer.setData('noteId', String(note.id)); e.dataTransfer.setData('fromDayId', String(day.id)); e.dataTransfer.effectAllowed = 'move'; dragDataRef.current = { noteId: String(note.id), fromDayId: String(day.id) }; setDraggingId(`note-${note.id}`) }}
|
||||
onDragEnd={() => { setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null }}
|
||||
onDragOver={e => { e.preventDefault(); e.stopPropagation(); if (dropTargetKey !== `note-${note.id}`) setDropTargetKey(`note-${note.id}`) }}
|
||||
onDrop={e => {
|
||||
@@ -2251,7 +2260,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
transition: 'background 0.1s', cursor: 'grab', userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
{canEditDays && !isMobile && <div className="dp-grip" style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
|
||||
{canEditDays && !dragDisabled && <div className="dp-grip" style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
|
||||
<GripVertical size={13} strokeWidth={1.8} />
|
||||
</div>}
|
||||
<div style={{ width: 28, height: 28, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: '50%', background: 'var(--bg-hover)', overflow: 'hidden' }}>
|
||||
|
||||
@@ -572,4 +572,36 @@ describe('Google Maps list import', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
// #1432: a tablet is a touch device at a desktop width. Before the fix, isTouch didn't
|
||||
// exist and drag was gated on width alone, so on an iPad the rows stayed draggable and a
|
||||
// scroll swipe started an HTML5 drag, which raised the drop-to-import overlay instead of
|
||||
// scrolling. These cases pin the desktop-width + coarse-pointer combination.
|
||||
describe('touch device at desktop width (#1432)', () => {
|
||||
const touchProps = { ...defaultProps, isMobile: false, isTouch: true };
|
||||
|
||||
it('FE-PLANNER-SIDEBAR-044: place rows are not draggable', () => {
|
||||
const place = buildPlace({ id: 7, name: 'Tablet Place' });
|
||||
render(<PlacesSidebar {...touchProps} places={[place]} />);
|
||||
const placeRow = screen.getByText('Tablet Place').closest('div[draggable]')!;
|
||||
expect(placeRow.getAttribute('draggable')).toBe('false');
|
||||
});
|
||||
|
||||
it('FE-PLANNER-SIDEBAR-045: dragging over the sidebar does not raise the drop-to-import overlay', () => {
|
||||
const place = buildPlace({ id: 7, name: 'Tablet Place' });
|
||||
const { container } = render(<PlacesSidebar {...touchProps} places={[place]} />);
|
||||
fireEvent.dragEnter(container.firstChild as HTMLElement);
|
||||
expect(screen.queryByText('Drop to import')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-SIDEBAR-046: a mouse-driven desktop keeps drag and the drop-to-import overlay', () => {
|
||||
const place = buildPlace({ id: 7, name: 'Desktop Place' });
|
||||
const { container } = render(<PlacesSidebar {...defaultProps} isTouch={false} places={[place]} />);
|
||||
const placeRow = screen.getByText('Desktop Place').closest('div[draggable]')!;
|
||||
expect(placeRow.getAttribute('draggable')).toBe('true');
|
||||
fireEvent.dragEnter(container.firstChild as HTMLElement);
|
||||
expect(screen.getByText('Drop to import')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,19 +17,20 @@ const PlacesSidebar = React.memo(function PlacesSidebar(props: PlacesSidebarProp
|
||||
sidebarDragOver, handleSidebarDragEnter, handleSidebarDragOver, handleSidebarDragLeave, handleSidebarDrop,
|
||||
selectMode, filtered, t, dayPickerPlace, listImportOpen,
|
||||
fileImportOpen, setFileImportOpen, sidebarDropFile, setSidebarDropFile, tripId, pushUndo,
|
||||
ctxMenu, isMobile, pendingDeleteIds, setPendingDeleteIds, onBulkDeleteConfirm,
|
||||
ctxMenu, isMobile, isTouch, pendingDeleteIds, setPendingDeleteIds, onBulkDeleteConfirm,
|
||||
categories, selectedIds, exitSelectMode, onBulkChangeCategory, categoryPickerOpen, setCategoryPickerOpen,
|
||||
collectionsEnabled, saveToListOpen, setSaveToListOpen,
|
||||
} = S
|
||||
const dragDisabled = isMobile || isTouch
|
||||
return (
|
||||
<div
|
||||
onDragEnter={isMobile ? undefined : handleSidebarDragEnter}
|
||||
onDragOver={isMobile ? undefined : handleSidebarDragOver}
|
||||
onDragLeave={isMobile ? undefined : handleSidebarDragLeave}
|
||||
onDrop={isMobile ? undefined : handleSidebarDrop}
|
||||
onDragEnter={dragDisabled ? undefined : handleSidebarDragEnter}
|
||||
onDragOver={dragDisabled ? undefined : handleSidebarDragOver}
|
||||
onDragLeave={dragDisabled ? undefined : handleSidebarDragLeave}
|
||||
onDrop={dragDisabled ? undefined : handleSidebarDrop}
|
||||
style={{ display: 'flex', flexDirection: 'column', height: '100%', fontFamily: "var(--font-system)", position: 'relative' }}
|
||||
>
|
||||
{!isMobile && sidebarDragOver && <PlacesDropOverlay {...S} />}
|
||||
{!dragDisabled && sidebarDragOver && <PlacesDropOverlay {...S} />}
|
||||
{/* Kopfbereich */}
|
||||
<PlacesHeader {...S} />
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ export function PlacesList(S: SidebarState) {
|
||||
const {
|
||||
filtered, scrollContainerRef, onScrollTopChange, filter, t, canEditPlaces, onAddPlace,
|
||||
categories, selectedPlaceId, plannedIds, inDaySet, selectedIds, selectMode, selectedDayId,
|
||||
isMobile, onPlaceClick, openContextMenu, onAssignToDay, toggleSelected, setDayPickerPlace, registerPlaceRow, tripId,
|
||||
isMobile, isTouch, onPlaceClick, openContextMenu, onAssignToDay, toggleSelected, setDayPickerPlace, registerPlaceRow, tripId,
|
||||
} = S
|
||||
// Plugin-contributed columns/actions for the places view, keyed by place id (#plugins).
|
||||
const contribFor = usePluginViewContributions('places', tripId)
|
||||
@@ -43,6 +43,7 @@ export function PlacesList(S: SidebarState) {
|
||||
selectedDayId={selectedDayId}
|
||||
canEditPlaces={canEditPlaces}
|
||||
isMobile={isMobile}
|
||||
isTouch={Boolean(isTouch)}
|
||||
t={t}
|
||||
onPlaceClick={onPlaceClick}
|
||||
onContextMenu={openContextMenu}
|
||||
|
||||
@@ -15,6 +15,8 @@ interface MemoPlaceRowProps {
|
||||
selectedDayId: number | null
|
||||
canEditPlaces: boolean
|
||||
isMobile: boolean
|
||||
/** Primary pointer is coarse — HTML5 drag would swallow the scroll gesture (#1432). */
|
||||
isTouch: boolean
|
||||
t: (key: string, params?: Record<string, any>) => string
|
||||
onPlaceClick: (id: number | null) => void
|
||||
onContextMenu: (e: React.MouseEvent, place: Place) => void
|
||||
@@ -26,19 +28,20 @@ interface MemoPlaceRowProps {
|
||||
|
||||
export const MemoPlaceRow = React.memo(function MemoPlaceRow({
|
||||
place, category: cat, isSelected, isPlanned, inDay, isChecked,
|
||||
selectMode, selectedDayId, canEditPlaces, isMobile, t,
|
||||
selectMode, selectedDayId, canEditPlaces, isMobile, isTouch, t,
|
||||
onPlaceClick, onContextMenu, onAssignToDay, toggleSelected, setDayPickerPlace, registerPlaceRow,
|
||||
}: MemoPlaceRowProps) {
|
||||
const hasGeometry = Boolean(place.route_geometry)
|
||||
const dragDisabled = isMobile || isTouch
|
||||
return (
|
||||
<div
|
||||
key={place.id}
|
||||
ref={element => registerPlaceRow(place.id, element)}
|
||||
aria-selected={isSelected}
|
||||
data-place-id={place.id}
|
||||
draggable={!selectMode && !isMobile}
|
||||
draggable={!selectMode && !dragDisabled}
|
||||
onDragStart={e => {
|
||||
if (isMobile) { e.preventDefault(); return }
|
||||
if (dragDisabled) { e.preventDefault(); return }
|
||||
e.dataTransfer.setData('placeId', String(place.id))
|
||||
e.dataTransfer.effectAllowed = 'copy'
|
||||
window.__dragData = { placeId: String(place.id) }
|
||||
@@ -56,7 +59,7 @@ export const MemoPlaceRow = React.memo(function MemoPlaceRow({
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
padding: '9px 14px 9px 16px',
|
||||
cursor: selectMode || isMobile ? 'pointer' : 'grab',
|
||||
cursor: selectMode || dragDisabled ? 'pointer' : 'grab',
|
||||
background: isChecked ? 'color-mix(in srgb, var(--accent) 8%, transparent)' : isSelected ? 'var(--border-faint)' : 'transparent',
|
||||
borderBottom: '1px solid var(--border-faint)',
|
||||
transition: 'background 0.1s',
|
||||
|
||||
@@ -31,6 +31,8 @@ export interface PlacesSidebarProps {
|
||||
onBulkChangeCategory?: (ids: number[], categoryId: number | null) => void
|
||||
days: Day[]
|
||||
isMobile: boolean
|
||||
/** Primary pointer is coarse — HTML5 drag would swallow the scroll gesture (#1432). */
|
||||
isTouch?: boolean
|
||||
onCategoryFilterChange?: (categoryIds: Set<string>) => void
|
||||
onPlacesFilterChange?: (filter: string) => void
|
||||
pushUndo?: (label: string, undoFn: () => Promise<void> | void) => void
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, it, expect, afterEach, vi } from 'vitest'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { useIsTouch } from './useIsTouch'
|
||||
|
||||
type Listener = (e: MediaQueryListEvent) => void
|
||||
|
||||
/** Fake matchMedia that matches only `(pointer: coarse)` and can fire a change. */
|
||||
function mockPointer(coarse: boolean) {
|
||||
const listeners: Listener[] = []
|
||||
vi.spyOn(window, 'matchMedia').mockImplementation((query: string) => ({
|
||||
matches: query === '(pointer: coarse)' ? coarse : false,
|
||||
media: query,
|
||||
addEventListener: (_: string, l: Listener) => listeners.push(l),
|
||||
removeEventListener: (_: string, l: Listener) => {
|
||||
const i = listeners.indexOf(l)
|
||||
if (i >= 0) listeners.splice(i, 1)
|
||||
},
|
||||
}) as unknown as MediaQueryList)
|
||||
return { fire: (matches: boolean) => listeners.forEach(l => l({ matches } as MediaQueryListEvent)) }
|
||||
}
|
||||
|
||||
describe('useIsTouch', () => {
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
// A tablet is a coarse-pointer device at a desktop width, so a width check misses it —
|
||||
// which is what left the places list undraggable-but-unscrollable on iPad (#1432).
|
||||
it('is true when the primary pointer is coarse, regardless of viewport width', () => {
|
||||
mockPointer(true)
|
||||
const { result } = renderHook(() => useIsTouch())
|
||||
expect(result.current).toBe(true)
|
||||
})
|
||||
|
||||
it('is false on a mouse-driven desktop', () => {
|
||||
mockPointer(false)
|
||||
const { result } = renderHook(() => useIsTouch())
|
||||
expect(result.current).toBe(false)
|
||||
})
|
||||
|
||||
it('reacts when the pointer type changes', () => {
|
||||
const mq = mockPointer(false)
|
||||
const { result } = renderHook(() => useIsTouch())
|
||||
expect(result.current).toBe(false)
|
||||
act(() => mq.fire(true))
|
||||
expect(result.current).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
/**
|
||||
* Returns true when the primary pointer is coarse (finger/stylus) — phones and
|
||||
* tablets, at any viewport width. A mouse-driven desktop is false, including a
|
||||
* touchscreen laptop, whose primary pointer is still the mouse.
|
||||
*
|
||||
* Viewport width is not a proxy for touch: an iPad is 820-1366 CSS px, so a
|
||||
* width-based check leaves HTML5 `draggable` armed there and the drag swallows
|
||||
* the scroll gesture (#1432).
|
||||
*/
|
||||
export function useIsTouch(): boolean {
|
||||
const [isTouch, setIsTouch] = useState(
|
||||
() => typeof window !== 'undefined' && window.matchMedia('(pointer: coarse)').matches,
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia('(pointer: coarse)')
|
||||
const handler = (e: MediaQueryListEvent) => setIsTouch(e.matches)
|
||||
setIsTouch(mq.matches)
|
||||
mq.addEventListener('change', handler)
|
||||
return () => mq.removeEventListener('change', handler)
|
||||
}, [])
|
||||
|
||||
return isTouch
|
||||
}
|
||||
+15
-7
@@ -144,19 +144,32 @@ html.dark button[class*="text-slate-600"][class*="border-slate-200"]:hover { bac
|
||||
html.dark .border-dashed.border-gray-300 { border-color: var(--border-primary) !important; }
|
||||
html.dark .bg-slate-50\/60, html.dark [class*="bg-slate-50/"] { background-color: transparent !important; }
|
||||
|
||||
/* Reorder buttons: desktop = original style; mobile = always visible, larger touch targets */
|
||||
/* Reorder buttons: desktop = original style; touch = always visible, larger touch targets.
|
||||
Keyed on pointer as well as width — a tablet is a wide touch device, and drag reorder is
|
||||
off wherever the pointer is coarse (#1432), so these buttons are the only affordance left. */
|
||||
.reorder-buttons {
|
||||
flex-direction: column;
|
||||
opacity: 0;
|
||||
/* Hidden means non-interactive. Opacity alone still hit-tests, which left an invisible
|
||||
but fully clickable target on every row that silently reordered the itinerary. */
|
||||
pointer-events: none;
|
||||
}
|
||||
.reorder-buttons button {
|
||||
background: none !important;
|
||||
padding: 1px 2px;
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
/* Reveal on row hover (and on keyboard focus, so the buttons are reachable by Tab). */
|
||||
.dp-row:hover .reorder-buttons,
|
||||
.dp-row:focus-within .reorder-buttons {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
@media (max-width: 767px), (pointer: coarse) {
|
||||
.reorder-buttons {
|
||||
flex-direction: row !important;
|
||||
opacity: 1 !important;
|
||||
/* There is no hover on touch, so these are always live — undo the base rule. */
|
||||
pointer-events: auto !important;
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
}
|
||||
@@ -175,11 +188,6 @@ html.dark .bg-slate-50\/60, html.dark [class*="bg-slate-50/"] { background-color
|
||||
}
|
||||
}
|
||||
|
||||
/* Ort-Zeile Hover: Sortier-Buttons anzeigen */
|
||||
.place-row:hover .reorder-btns {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
/* Ladeanimation */
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
|
||||
@@ -203,7 +203,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
mobileSidebarOpen, setMobileSidebarOpen, mobilePlanScrollTopRef, mobilePlacesScrollTopRef,
|
||||
deletePlaceId, setDeletePlaceId, deletePlaceIds, setDeletePlaceIds,
|
||||
visibleConnections, setVisibleConnections, toggleConnection, mapTransportDetail, setMapTransportDetail,
|
||||
isMobile, mapCategoryFilter, setMapCategoryFilter, mapPlacesFilter, setMapPlacesFilter,
|
||||
isMobile, isTouch, mapCategoryFilter, setMapCategoryFilter, mapPlacesFilter, setMapPlacesFilter,
|
||||
expandedDayIds, setExpandedDayIds, mapPlaces,
|
||||
route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay,
|
||||
handleSelectDay, handlePlaceClick, handleMarkerClick, handleMapClick, handleMapContextMenu, openAddPlaceFromPoi,
|
||||
@@ -387,6 +387,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
}}>
|
||||
<DayPlanSidebar
|
||||
isMobile={isMobile}
|
||||
isTouch={isTouch}
|
||||
tripId={tripId}
|
||||
trip={trip}
|
||||
days={days}
|
||||
@@ -498,6 +499,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
pushUndo={pushUndo}
|
||||
days={days}
|
||||
isMobile={false}
|
||||
isTouch={isTouch}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -21,6 +21,7 @@ import { useRouteCalculation } from '../../hooks/useRouteCalculation'
|
||||
import { usePlaceSelection } from '../../hooks/usePlaceSelection'
|
||||
import { usePlannerHistory } from '../../hooks/usePlannerHistory'
|
||||
import { useAirtrailConnection } from '../../hooks/useAirtrailConnection'
|
||||
import { useIsTouch } from '../../hooks/useIsTouch'
|
||||
import { usePluginStore } from '../../store/pluginStore'
|
||||
import type { Accommodation, TripMember, Day, Place, Reservation } from '../../types'
|
||||
import { resolvePoolAssignmentId } from './tripPlannerModel'
|
||||
@@ -269,6 +270,9 @@ export function useTripPlanner() {
|
||||
mq.addEventListener('change', handler)
|
||||
return () => mq.removeEventListener('change', handler)
|
||||
}, [])
|
||||
// Layout is width-driven (isMobile); drag affordances are pointer-driven (isTouch).
|
||||
// Conflating them is what left a tablet's places list undraggable-but-unscrollable (#1432).
|
||||
const isTouch = useIsTouch()
|
||||
|
||||
// Start photo fetches during splash screen so images are ready when map mounts
|
||||
useEffect(() => {
|
||||
@@ -910,7 +914,7 @@ export function useTripPlanner() {
|
||||
mobileSidebarOpen, setMobileSidebarOpen, mobilePlanScrollTopRef, mobilePlacesScrollTopRef,
|
||||
deletePlaceId, setDeletePlaceId, deletePlaceIds, setDeletePlaceIds,
|
||||
visibleConnections, setVisibleConnections, toggleConnection, mapTransportDetail, setMapTransportDetail,
|
||||
isMobile, mapCategoryFilter, setMapCategoryFilter, mapPlacesFilter, setMapPlacesFilter,
|
||||
isMobile, isTouch, mapCategoryFilter, setMapCategoryFilter, mapPlacesFilter, setMapPlacesFilter,
|
||||
expandedDayIds, setExpandedDayIds, mapPlaces,
|
||||
route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay,
|
||||
handleSelectDay, handlePlaceClick, handleMarkerClick, handleMapClick, handleMapContextMenu, openAddPlaceFromPoi,
|
||||
|
||||
@@ -5,23 +5,39 @@ import { maybeInstallTouchDragPolyfill } from './touchDragPolyfill'
|
||||
// document-level touch listeners the package installs on import.
|
||||
vi.mock('drag-drop-touch', () => ({}))
|
||||
|
||||
function setWidth(w: number) {
|
||||
Object.defineProperty(window, 'innerWidth', { value: w, configurable: true, writable: true })
|
||||
// The gate is pointer-based, not width-based. The polyfill synthesises HTML5 drag events
|
||||
// from touchmove, so on a coarse-pointer device it turns a scroll swipe into a drag
|
||||
// (#1432) and fabricates a dblclick that zooms the map (#1440). Drag reorder is disabled
|
||||
// on those devices anyway, leaving one device class with a job for it: the hybrid laptop
|
||||
// — mouse as primary pointer, touchscreen also present.
|
||||
function mockPointer(matching: string[]) {
|
||||
vi.spyOn(window, 'matchMedia').mockImplementation(
|
||||
(query: string) => ({ matches: matching.includes(query), media: query }) as MediaQueryList,
|
||||
)
|
||||
}
|
||||
|
||||
describe('maybeInstallTouchDragPolyfill', () => {
|
||||
const original = window.innerWidth
|
||||
afterEach(() => setWidth(original))
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
it('does not load the polyfill on mobile viewports (<1024px)', () => {
|
||||
setWidth(390)
|
||||
expect(maybeInstallTouchDragPolyfill()).toBeUndefined()
|
||||
})
|
||||
|
||||
it('loads the polyfill on large viewports (>=1024px)', async () => {
|
||||
setWidth(1440)
|
||||
it('loads on a hybrid laptop — mouse primary, touchscreen available', async () => {
|
||||
mockPointer(['(pointer: fine) and (any-pointer: coarse)'])
|
||||
const result = maybeInstallTouchDragPolyfill()
|
||||
expect(result).toBeInstanceOf(Promise)
|
||||
await expect(result).resolves.toBeDefined()
|
||||
})
|
||||
|
||||
it('does not load on a tablet — coarse primary pointer at a desktop width (#1432)', () => {
|
||||
mockPointer(['(pointer: coarse)', '(any-pointer: coarse)'])
|
||||
expect(maybeInstallTouchDragPolyfill()).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not load on a phone', () => {
|
||||
mockPointer(['(pointer: coarse)', '(any-pointer: coarse)'])
|
||||
expect(maybeInstallTouchDragPolyfill()).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not load on a plain mouse-driven desktop', () => {
|
||||
mockPointer(['(pointer: fine)'])
|
||||
expect(maybeInstallTouchDragPolyfill()).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,13 +6,14 @@
|
||||
* dblclick fires the default double-click-zoom, so two quick one-finger pans zoomed
|
||||
* instead of panning (#1440).
|
||||
*
|
||||
* Reorder DnD is disabled on mobile viewports (#1432), so the polyfill has no job below
|
||||
* the lg breakpoint — skip it there to remove the phantom-dblclick source on phones
|
||||
* while keeping touch DnD on desktop / large (touch) viewports.
|
||||
* Reorder DnD is disabled wherever the primary pointer is coarse (#1432), so the only
|
||||
* device class left with a job for the polyfill is the hybrid laptop: a mouse as the
|
||||
* primary pointer, with a touchscreen also available. Loading it anywhere else — notably
|
||||
* on tablets, which a width check misclassifies as desktop — re-arms the very gesture
|
||||
* hijack #1432 removed, and drags the #1440 phantom-dblclick along with it.
|
||||
*/
|
||||
export function maybeInstallTouchDragPolyfill(): Promise<unknown> | void {
|
||||
if (typeof window === 'undefined') return
|
||||
// Mirrors useIsMobile's SSR-safe initial check (lg breakpoint = 1024px).
|
||||
if (window.innerWidth < 1024) return
|
||||
if (!window.matchMedia('(pointer: fine) and (any-pointer: coarse)').matches) return
|
||||
return import('drag-drop-touch')
|
||||
}
|
||||
|
||||
Generated
+24
-47
@@ -6144,8 +6144,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm64": {
|
||||
"version": "4.62.0",
|
||||
@@ -6159,8 +6158,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||
"version": "4.62.0",
|
||||
@@ -6174,8 +6172,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-x64": {
|
||||
"version": "4.62.0",
|
||||
@@ -6189,8 +6186,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-arm64": {
|
||||
"version": "4.62.0",
|
||||
@@ -6204,8 +6200,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-x64": {
|
||||
"version": "4.62.0",
|
||||
@@ -6219,8 +6214,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||
"version": "4.62.0",
|
||||
@@ -6234,8 +6228,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||
"version": "4.62.0",
|
||||
@@ -6249,8 +6242,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||
"version": "4.62.0",
|
||||
@@ -6264,8 +6256,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||
"version": "4.62.0",
|
||||
@@ -6292,8 +6283,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
||||
"version": "4.62.0",
|
||||
@@ -6307,8 +6297,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
||||
"version": "4.62.0",
|
||||
@@ -6322,8 +6311,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
||||
"version": "4.62.0",
|
||||
@@ -6337,8 +6325,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||
"version": "4.62.0",
|
||||
@@ -6352,8 +6339,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
||||
"version": "4.62.0",
|
||||
@@ -6367,8 +6353,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||
"version": "4.62.0",
|
||||
@@ -6382,8 +6367,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||
"version": "4.62.0",
|
||||
@@ -6397,8 +6381,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||
"version": "4.62.0",
|
||||
@@ -6425,8 +6408,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openharmony-arm64": {
|
||||
"version": "4.62.0",
|
||||
@@ -6440,8 +6422,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||
"version": "4.62.0",
|
||||
@@ -6455,8 +6436,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||
"version": "4.62.0",
|
||||
@@ -6470,8 +6450,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
||||
"version": "4.62.0",
|
||||
@@ -6485,8 +6464,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||
"version": "4.62.0",
|
||||
@@ -6500,8 +6478,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@scarf/scarf": {
|
||||
"version": "1.4.0",
|
||||
@@ -21237,7 +21214,7 @@
|
||||
"name": "@trek/server",
|
||||
"version": "3.3.0",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.28.0",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"@nestjs/common": "^11.1.24",
|
||||
"@nestjs/core": "^11.1.24",
|
||||
"@nestjs/platform-express": "^11.1.24",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "trek-plugin-sdk",
|
||||
"version": "1.4.1",
|
||||
"version": "1.5.0",
|
||||
"description": "SDK for building TREK plugins: types, definePlugin, a mock host, manifest validation, and the create-trek-plugin / trek-plugin CLIs.",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
+51
-16
@@ -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},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)`);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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:');
|
||||
|
||||
@@ -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 ?? []) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -110,7 +110,7 @@ describe('scaffold + validate dependencies', () => {
|
||||
});
|
||||
|
||||
describe('validateManifest dependency rules', () => {
|
||||
const base = { id: 'my-plug', name: 'My Plug', version: '1.0.0', type: 'integration', permissions: ['db:own'] };
|
||||
const base = { id: 'my-plug', name: 'My Plug', version: '1.0.0', type: 'integration', permissions: ['db:own'], trek: '>=3.2.0 <4.0.0' };
|
||||
it('accepts valid requiredAddons + pluginDependencies', () => {
|
||||
const r = validateManifest({ ...base, requiredAddons: ['budget', 'journey'], pluginDependencies: [{ id: 'koffi', version: '>=1.0.0 <2.0.0' }] });
|
||||
expect(r.ok).toBe(true);
|
||||
@@ -205,7 +205,7 @@ describe('notification-channel template', () => {
|
||||
describe('capabilities.notificationChannel validation', () => {
|
||||
const base = {
|
||||
id: 'chan', name: 'Chan', version: '1.0.0', apiVersion: 1, type: 'integration',
|
||||
nativeModules: false, permissions: ['hook:notification-channel'],
|
||||
nativeModules: false, permissions: ['hook:notification-channel'], trek: '>=3.2.0 <4.0.0',
|
||||
};
|
||||
|
||||
it('accepts a narrowed event list', () => {
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
// publish shells out to git + gh; drive those from here so we can assert what it WOULD run.
|
||||
const calls: Array<{ bin: string; args: string[] }> = [];
|
||||
let releaseExistsOnRemote = false;
|
||||
|
||||
vi.mock('node:child_process', () => ({
|
||||
execFileSync: (bin: string, args: string[]) => {
|
||||
calls.push({ bin, args });
|
||||
if (bin === 'gh' && args[0] === 'release' && args[1] === 'view') {
|
||||
if (!releaseExistsOnRemote) throw new Error('release not found');
|
||||
return Buffer.from('');
|
||||
}
|
||||
// `git rev-parse <tag>^{commit}` — pretend the tag does not exist yet.
|
||||
if (bin === 'git' && args.includes('rev-parse')) throw new Error('unknown revision');
|
||||
return Buffer.from('');
|
||||
},
|
||||
}));
|
||||
|
||||
const { publishPlugin } = await import('../src/cli/publish.js');
|
||||
const { scaffold } = await import('../src/cli/create.js');
|
||||
|
||||
describe('publish — a released artifact is immutable', () => {
|
||||
let tmp: string;
|
||||
let dir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
calls.length = 0;
|
||||
releaseExistsOnRemote = false;
|
||||
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'publish-'));
|
||||
scaffold('immutable-plug', 'integration', tmp);
|
||||
dir = path.join(tmp, 'immutable-plug');
|
||||
});
|
||||
afterEach(() => { fs.rmSync(tmp, { recursive: true, force: true }); });
|
||||
|
||||
const run = (force?: boolean) => publishPlugin({
|
||||
dir, repo: 'someone/trek-plugin-immutable-plug', tag: 'v1.0.0',
|
||||
now: '2026-01-01T00:00:00Z', skipPreflight: true, force, log: () => {},
|
||||
});
|
||||
|
||||
it('REFUSES to overwrite an existing release, and never uploads', async () => {
|
||||
releaseExistsOnRemote = true;
|
||||
// The registry pins the artifact's sha256. Rewriting the bytes of a release that is
|
||||
// already in the registry breaks that pin for everyone who installed that version.
|
||||
await expect(run()).rejects.toThrow(/already exists/i);
|
||||
expect(calls.some((c) => c.bin === 'gh' && c.args.includes('upload'))).toBe(false);
|
||||
expect(calls.some((c) => c.bin === 'gh' && c.args.includes('--clobber'))).toBe(false);
|
||||
});
|
||||
|
||||
it('--force overwrites deliberately (for a release never merged into the registry)', async () => {
|
||||
releaseExistsOnRemote = true;
|
||||
await run(true).catch(() => {}); // submit/preflight are stubbed out; we only care about the gh calls
|
||||
const upload = calls.find((c) => c.bin === 'gh' && c.args.includes('upload'));
|
||||
expect(upload?.args).toContain('--clobber');
|
||||
});
|
||||
|
||||
it('creates the release normally when it does not exist yet', async () => {
|
||||
releaseExistsOnRemote = false;
|
||||
await run().catch(() => {});
|
||||
expect(calls.some((c) => c.bin === 'gh' && c.args[1] === 'create')).toBe(true);
|
||||
expect(calls.some((c) => c.args.includes('--clobber'))).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps plugin.zip — the entry sha256 must be hashed from the uploaded bytes', async () => {
|
||||
await run().catch(() => {});
|
||||
// A re-pack on another machine/SDK version can produce different bytes (CRLF, walk
|
||||
// order), so deleting the artifact after publishing made a follow-up `entry`/`sign` wrong.
|
||||
expect(fs.existsSync(path.join(dir, 'plugin.zip'))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import path from 'node:path';
|
||||
import zlib from 'node:zlib';
|
||||
import { createRequire } from 'node:module';
|
||||
import { definePlugin, PLUGIN_API_VERSION, validateManifest, createMockHost } from '../src/index.js';
|
||||
import { isUnboundedRange } from '../src/manifest.js';
|
||||
import { scaffold } from '../src/cli/create.js';
|
||||
import { validatePluginDir } from '../src/cli/validate.js';
|
||||
import { makeZip, listZipNames } from '../src/zip.js';
|
||||
@@ -44,7 +45,7 @@ describe('definePlugin + api version', () => {
|
||||
});
|
||||
|
||||
describe('validateManifest', () => {
|
||||
const base = { id: 'flight-tracker', name: 'Flight', version: '1.0.0', type: 'widget', apiVersion: 1 };
|
||||
const base = { id: 'flight-tracker', name: 'Flight', version: '1.0.0', type: 'widget', apiVersion: 1, trek: '>=3.2.0 <4.0.0' };
|
||||
it('accepts a valid manifest', () => {
|
||||
expect(validateManifest(base).ok).toBe(true);
|
||||
});
|
||||
@@ -587,14 +588,14 @@ describe('pack + entry (publishing automation)', () => {
|
||||
it('refuses a plugin that ships a native binary', () => {
|
||||
const bad = path.join(tmp, 'bad');
|
||||
fs.mkdirSync(path.join(bad, 'server'), { recursive: true });
|
||||
fs.writeFileSync(path.join(bad, 'trek-plugin.json'), JSON.stringify({ id: 'bad-plug', name: 'Bad', version: '1.0.0', type: 'integration', permissions: [], egress: [] }));
|
||||
fs.writeFileSync(path.join(bad, 'trek-plugin.json'), JSON.stringify({ id: 'bad-plug', name: 'Bad', version: '1.0.0', type: 'integration', permissions: [], egress: [], trek: '>=3.2.0 <4.0.0' }));
|
||||
fs.writeFileSync(path.join(bad, 'server', 'index.js'), 'module.exports={}');
|
||||
fs.writeFileSync(path.join(bad, 'server', 'thing.node'), Buffer.from([1, 2, 3]));
|
||||
fs.writeFileSync(path.join(bad, 'README.md'), '# Bad\n\ncontent');
|
||||
expect(() => packPluginDir(bad, path.join(tmp, 'x.zip'))).toThrow(/native binaries/);
|
||||
});
|
||||
|
||||
it('builds a registry entry with sha256/size/commit/minTrekVersion filled in', () => {
|
||||
it('builds a registry entry with sha256/size/commit/trek filled in', () => {
|
||||
const out = path.join(tmp, 'plugin.zip');
|
||||
const packed = packPluginDir(koffi, out);
|
||||
const entry = buildEntry({
|
||||
@@ -607,11 +608,43 @@ describe('pack + entry (publishing automation)', () => {
|
||||
expect(v.sha256).toBe(packed.sha256);
|
||||
expect(v.size).toBe(packed.size);
|
||||
expect(v.commitSha).toBe('a'.repeat(40));
|
||||
expect(v.minTrekVersion).toBe('3.2.0');
|
||||
// The RAW range is the one compatibility field an entry carries. minTrekVersion is gone:
|
||||
// it restated the range's lower bound in a weaker form (it cannot express the exclusive
|
||||
// upper bound), so the entry used to drop "<4.0.0" silently and a TREK 4 server read
|
||||
// "requires 3.2.0+" and considered the plugin compatible.
|
||||
expect(v.trek).toBe('>=3.2.0 <4.0.0');
|
||||
expect(v).not.toHaveProperty('minTrekVersion');
|
||||
expect(v.downloadUrl).toBe('https://github.com/mauriceboe/trek-plugin-koffi/releases/download/v1.0.0/plugin.zip');
|
||||
expect(v.nativeModules).toBe(false);
|
||||
});
|
||||
|
||||
it('publishes an upper-bound-only range verbatim, with no floor to get wrong', () => {
|
||||
// This is why the derived floor is gone. "<4.0.0" has a first X.Y.Z of 4.0.0 but a lower
|
||||
// bound of 0.0.0, and the old regex published 4.0.0 as the MINIMUM — advertising a plugin
|
||||
// that supports everything BELOW 4 as requiring 4+, the precise inverse of what its author
|
||||
// wrote. A field that only ever restates the range cannot be wrong if it does not exist.
|
||||
const entry = buildEntry({ ...entryOptsFor('upper-only', '<4.0.0'), now: '2026-07-04T00:00:00.000Z' });
|
||||
expect(entry.versions[0].trek).toBe('<4.0.0');
|
||||
expect(entry.versions[0]).not.toHaveProperty('minTrekVersion');
|
||||
});
|
||||
|
||||
it('refuses to publish a plugin with no usable trek range', () => {
|
||||
expect(() => buildEntry({ ...entryOptsFor('no-trek', undefined), now: '2026-07-04T00:00:00.000Z' }))
|
||||
.toThrow(/no valid "trek" version range/);
|
||||
expect(() => buildEntry({ ...entryOptsFor('bad-trek', '>=4.0.0 <3.0.0'), now: '2026-07-04T00:00:00.000Z' }))
|
||||
.toThrow(/no valid "trek" version range/);
|
||||
});
|
||||
|
||||
/** A minimal plugin dir + artifact for buildEntry (which only needs the zip to exist). */
|
||||
function entryOptsFor(id: string, trek: string | undefined) {
|
||||
const dir = path.join(tmp, id);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'trek-plugin.json'), JSON.stringify({ id, name: id, version: '1.0.0', type: 'integration', ...(trek === undefined ? {} : { trek }) }));
|
||||
const zipPath = path.join(tmp, `${id}.zip`);
|
||||
fs.writeFileSync(zipPath, makeZip([{ name: 'trek-plugin.json', data: Buffer.from('{}') }]));
|
||||
return { dir, repo: 'a/b', tag: 'v1.0.0', zipPath, commit: 'a'.repeat(40) };
|
||||
}
|
||||
|
||||
it('--merge prepends the new version onto an existing entry, newest-first', () => {
|
||||
const out = path.join(tmp, 'plugin.zip');
|
||||
packPluginDir(koffi, out);
|
||||
@@ -808,7 +841,7 @@ describe('mock-host inter-plugin (plugins.call + events.emit)', () => {
|
||||
});
|
||||
|
||||
describe('validateManifest capabilities.provides/emits', () => {
|
||||
const base = { id: 'my-plug', name: 'My Plug', version: '1.0.0', type: 'integration', permissions: ['db:own'] };
|
||||
const base = { id: 'my-plug', name: 'My Plug', version: '1.0.0', type: 'integration', permissions: ['db:own'], trek: '>=3.2.0 <4.0.0' };
|
||||
it('accepts well-formed provides + emits', () => {
|
||||
const r = validateManifest({ ...base, capabilities: { provides: ['computeRate'], emits: ['rate.updated'] } });
|
||||
expect(r.ok).toBe(true);
|
||||
@@ -818,3 +851,37 @@ describe('validateManifest capabilities.provides/emits', () => {
|
||||
expect(validateManifest({ ...base, capabilities: { emits: 'nope' } }).ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The `trek` range is what TREK gates installs on, so it has to be caught HERE — the same
|
||||
* validateManifest the registry CI runs — rather than by a rejected install.
|
||||
*/
|
||||
describe('validateManifest: the trek range', () => {
|
||||
const base = { id: 'my-plug', name: 'My Plug', version: '1.0.0', type: 'integration', permissions: ['db:own'] };
|
||||
|
||||
it('requires a range', () => {
|
||||
const r = validateManifest({ ...base });
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.errors.join()).toMatch(/missing "trek"/);
|
||||
});
|
||||
|
||||
it('rejects a range that is not semver, and one nothing can satisfy', () => {
|
||||
expect(validateManifest({ ...base, trek: '3.2+' }).ok).toBe(false);
|
||||
// Syntactically valid, semantically empty: no TREK could ever run it.
|
||||
expect(validateManifest({ ...base, trek: '>=4.0.0 <3.0.0' }).ok).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts the ranges authors really write, and carries the range through', () => {
|
||||
for (const trek of ['>=3.2.0 <4.0.0', '^3.2.0', '>=3']) {
|
||||
const r = validateManifest({ ...base, trek });
|
||||
expect(r.ok, trek).toBe(true);
|
||||
expect(r.manifest?.trek).toBe(trek);
|
||||
}
|
||||
});
|
||||
|
||||
it('allows "*" (it is a legal claim) but validate warns — it is nearly always a lie', () => {
|
||||
expect(validateManifest({ ...base, trek: '*' }).ok).toBe(true);
|
||||
expect(isUnboundedRange('*')).toBe(true);
|
||||
expect(isUnboundedRange('>=3.2.0 <4.0.0')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* The SDK's author-signature verifier.
|
||||
*
|
||||
* It exists so `preflight` can answer the question the REGISTRY's CI will ask, before the
|
||||
* author opens the PR. That makes agreement with the other two implementations the entire
|
||||
* point: TREK's host verifier (server/src/nest/plugins/install/verify-signature.ts) and the
|
||||
* registry's port of it (TREK-Plugins scripts/lib/verify-signature.mjs). A gate that is
|
||||
* merely *similar* is worse than none — a green preflight is trusted, and an entry the
|
||||
* registry rejects is one nobody can install.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import crypto from 'node:crypto';
|
||||
import { verifyAuthorSignature, checkSignatureShape, SignatureError } from '../src/cli/verify-signature.js';
|
||||
import { generateKeypair, signArtifact, publicKeyBase64, loadPrivateKey } from '../src/cli/sign.js';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
/** A throwaway Ed25519 keypair, via the SDK's own keygen (not a hand-rolled one). */
|
||||
function keypair(): { key: crypto.KeyObject; pub: string } {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'trek-sig-'));
|
||||
const keyPath = path.join(dir, 'signing.key');
|
||||
generateKeypair(keyPath);
|
||||
const key = loadPrivateKey(keyPath);
|
||||
return { key, pub: publicKeyBase64(key) };
|
||||
}
|
||||
|
||||
const BYTES = Buffer.from('a plugin artifact, pretend this is a zip');
|
||||
|
||||
describe('verifyAuthorSignature', () => {
|
||||
// The round-trip that actually ships: `sign` emits a bare 64-byte signature over the raw
|
||||
// bytes and `keygen` a bare 32-byte key, and all three verifiers must accept that pair.
|
||||
it('accepts what the SDK itself signs', () => {
|
||||
const { key, pub } = keypair();
|
||||
expect(verifyAuthorSignature(BYTES, signArtifact(BYTES, key), pub)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a signature over DIFFERENT bytes (a re-pack after signing)', () => {
|
||||
const { key, pub } = keypair();
|
||||
const sig = signArtifact(BYTES, key);
|
||||
expect(verifyAuthorSignature(Buffer.concat([BYTES, Buffer.from('!')]), sig, pub)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a signature made with a DIFFERENT key', () => {
|
||||
const a = keypair();
|
||||
const b = keypair();
|
||||
expect(verifyAuthorSignature(BYTES, signArtifact(BYTES, a.key), b.pub)).toBe(false);
|
||||
});
|
||||
|
||||
// A well-formed-but-wrong signature returns false; a MALFORMED one throws. The host draws
|
||||
// exactly this line (SignatureError vs a false return), and the registry mirrors it — both
|
||||
// end up refusing the install, but only the throw carries a "your key is garbage" message.
|
||||
it('throws SignatureError on a malformed key or signature, rather than returning false', () => {
|
||||
const { key, pub } = keypair();
|
||||
const sig = signArtifact(BYTES, key);
|
||||
expect(() => verifyAuthorSignature(BYTES, sig, 'not-base64-at-all!!')).toThrow(SignatureError);
|
||||
expect(() => verifyAuthorSignature(BYTES, 'AAAA', pub)).toThrow(SignatureError);
|
||||
expect(() => verifyAuthorSignature(BYTES, sig, '')).toThrow(SignatureError);
|
||||
});
|
||||
|
||||
// The two-line minisign form, which an author may paste in from a `minisign` keypair. The
|
||||
// comment line must be skipped rather than parsed as the payload.
|
||||
it('accepts a minisign-style key with an "untrusted comment" line', () => {
|
||||
const { key, pub } = keypair();
|
||||
const sig = signArtifact(BYTES, key);
|
||||
const twoLine = `untrusted comment: minisign public key ABC\n${pub}\n`;
|
||||
expect(verifyAuthorSignature(BYTES, sig, twoLine)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkSignatureShape', () => {
|
||||
it('passes an entry that is unsigned throughout, and one that is fully signed', () => {
|
||||
const { key, pub } = keypair();
|
||||
const sig = signArtifact(BYTES, key);
|
||||
expect(checkSignatureShape({ versions: [{ version: '1.0.0' }] })).toEqual([]);
|
||||
expect(checkSignatureShape({ authorPublicKey: pub, versions: [{ version: '1.0.0', signature: sig }] })).toEqual([]);
|
||||
});
|
||||
|
||||
// Both halves of a half-signed entry. TREK refuses to install either ("incomplete
|
||||
// signature: an author key and a version signature must both be present"), so merging one
|
||||
// ships an entry nobody can use — the point is to catch it before the PR, not at review.
|
||||
it('refuses a signature with no key', () => {
|
||||
const { key } = keypair();
|
||||
const problems = checkSignatureShape({ versions: [{ version: '1.0.0', signature: signArtifact(BYTES, key) }] });
|
||||
expect(problems).toHaveLength(1);
|
||||
expect(problems[0]).toMatch(/no authorPublicKey/);
|
||||
});
|
||||
|
||||
it('refuses a key with no signed version', () => {
|
||||
const { pub } = keypair();
|
||||
const problems = checkSignatureShape({ authorPublicKey: pub, versions: [{ version: '1.0.0' }] });
|
||||
expect(problems).toHaveLength(1);
|
||||
expect(problems[0]).toMatch(/no version carries a signature/);
|
||||
});
|
||||
|
||||
it('reports a malformed key and a malformed signature by name', () => {
|
||||
const { key, pub } = keypair();
|
||||
expect(checkSignatureShape({ authorPublicKey: 'nonsense', versions: [{ version: '1.0.0', signature: signArtifact(BYTES, key) }] })[0])
|
||||
.toMatch(/authorPublicKey is not a valid/);
|
||||
expect(checkSignatureShape({ authorPublicKey: pub, versions: [{ version: '2.1.0', signature: 'AAAA' }] })[0])
|
||||
.toMatch(/2\.1\.0: signature is malformed/);
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -22,7 +22,7 @@
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.28.0",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"@nestjs/common": "^11.1.24",
|
||||
"@nestjs/core": "^11.1.24",
|
||||
"@nestjs/platform-express": "^11.1.24",
|
||||
|
||||
@@ -3626,6 +3626,40 @@ function runMigrations(db: Database.Database): void {
|
||||
);
|
||||
`);
|
||||
},
|
||||
|
||||
// Why a plugin's update was REFUSED by the signature check (#plugins). A refused
|
||||
// update leaves a working plugin pinned at its old version — previously the reason
|
||||
// lived only in a transient toast, so the plugin quietly stopped updating and the
|
||||
// admin had to re-attempt an update to rediscover why. Record it instead.
|
||||
//
|
||||
// `update_block_version` is the registry version that was refused: once the registry
|
||||
// offers something NEWER, the block describes an artifact nobody is being offered
|
||||
// anymore, so it reads as stale and the admin can just re-attempt (the next install
|
||||
// re-verifies and either succeeds or re-blocks with fresh values). Deliberately no
|
||||
// `status = 'error'` — the plugin still runs fine on its old code.
|
||||
() => {
|
||||
for (const col of ['update_block_code TEXT', 'update_block_detail TEXT', 'update_block_version TEXT']) {
|
||||
try {
|
||||
db.exec(`ALTER TABLE plugins ADD COLUMN ${col};`);
|
||||
} catch (err) {
|
||||
console.warn('[migrations] Non-fatal migration step failed:', err);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// The semver RANGE of TREK versions a plugin declares it supports (its manifest's
|
||||
// `trek`, e.g. ">=3.2.0 <4.0.0"). The existing `min_trek_version` only carries the
|
||||
// lower bound, so it cannot express "stops working at 4.0" — which is precisely the
|
||||
// case the activation gate has to catch after a TREK upgrade. Kept nullable: a plugin
|
||||
// installed before this column existed has no range recorded, and the gate refuses to
|
||||
// activate it rather than guessing (see TREK_VERSION_UNKNOWN).
|
||||
() => {
|
||||
try {
|
||||
db.exec('ALTER TABLE plugins ADD COLUMN trek_range TEXT;');
|
||||
} catch (err) {
|
||||
console.warn('[migrations] Non-fatal migration step failed:', err);
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
if (currentVersion < migrations.length) {
|
||||
|
||||
+38
-3
@@ -9,7 +9,7 @@ import { isAddonEnabled } from '../services/adminService';
|
||||
import { ADDON_IDS } from '../addons';
|
||||
import { registerResources } from './resources';
|
||||
import { registerTools } from './tools';
|
||||
import { McpSession, sessions, revokeUserSessions, revokeUserSessionsForClient } from './sessionManager';
|
||||
import { McpSession, sessions, revokeUserSessions, revokeUserSessionsForClient, evictOldestSessionForUser } from './sessionManager';
|
||||
import { resolveSessionTtlMs, resolveKeepaliveMs } from './config';
|
||||
import { writeAudit, getClientIp } from '../services/auditLog';
|
||||
import { getMcpSafeUrl } from '../services/notifications';
|
||||
@@ -183,6 +183,12 @@ const sessionSweepInterval = setInterval(() => {
|
||||
// Prevent the interval from keeping the process alive if nothing else is running
|
||||
sessionSweepInterval.unref();
|
||||
|
||||
/** JSON-RPC 2.0 error body. MCP clients parse this and show `message`; a bare `{ error }`
|
||||
* envelope isn't valid JSON-RPC, so they fall back to a generic "tool execution failed". */
|
||||
function jsonRpcError(message: string, code = -32000): object {
|
||||
return { jsonrpc: '2.0', error: { code, message }, id: null };
|
||||
}
|
||||
|
||||
function setAuthChallenge(res: Response, error = 'invalid_token'): void {
|
||||
const base = (getMcpSafeUrl() || '').replace(/\/+$/, '');
|
||||
// RFC 9728 §5: resource with path component /mcp → PRM URL must include the path
|
||||
@@ -304,9 +310,25 @@ export async function mcpHandler(req: Request, res: Response): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
// A client that reuses its session sends the header on every call after initialize, so a
|
||||
// steady stream of session-less POSTs means the id isn't making it back to the client —
|
||||
// usually a reverse proxy dropping Mcp-Session-Id, or a client that ignores it. Say so,
|
||||
// because the visible symptom (sessions piling up to the cap) points nowhere near the cause.
|
||||
console.warn(
|
||||
`[MCP] POST without mcp-session-id for user ${user.id} — starting a new session. ` +
|
||||
'If this repeats on every tool call, the Mcp-Session-Id response header is not reaching ' +
|
||||
'the client (check that your reverse proxy forwards it).',
|
||||
);
|
||||
|
||||
// At the cap, evict this user's coldest session rather than refusing the request: a client
|
||||
// stuck re-initializing would otherwise be locked out until the process restarted.
|
||||
if (countSessionsForUser(user.id) >= MAX_SESSIONS_PER_USER) {
|
||||
res.status(429).json({ error: 'Session limit reached. Close an existing session before opening a new one.' });
|
||||
return;
|
||||
const evicted = evictOldestSessionForUser(user.id);
|
||||
if (!evicted) {
|
||||
res.status(429).json(jsonRpcError('Session limit reached. Close an existing session before opening a new one.'));
|
||||
return;
|
||||
}
|
||||
console.log(`[MCP] Session limit (${MAX_SESSIONS_PER_USER}) reached for user ${user.id} — evicted idle session ${evicted}`);
|
||||
}
|
||||
|
||||
// Create a new per-user MCP server and session
|
||||
@@ -348,6 +370,10 @@ export async function mcpHandler(req: Request, res: Response): Promise<void> {
|
||||
sessions.delete(sid);
|
||||
},
|
||||
});
|
||||
// Belt-and-braces: whenever the SDK closes the transport for any reason, drop the map entry.
|
||||
transport.onclose = () => {
|
||||
if (transport.sessionId) sessions.delete(transport.sessionId);
|
||||
};
|
||||
|
||||
logToolCallAudit(req, user.id, clientId);
|
||||
armSseKeepalive(res);
|
||||
@@ -359,6 +385,15 @@ export async function mcpHandler(req: Request, res: Response): Promise<void> {
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: 'Internal MCP error' });
|
||||
}
|
||||
} finally {
|
||||
// Only an `initialize` request assigns a sessionId (via onsessioninitialized). Any other
|
||||
// session-less POST is rejected by the SDK with "Server not initialized" — and this server,
|
||||
// with its ~200 registered tools, would otherwise be orphaned: never in `sessions`, never
|
||||
// swept, never closed. Reap it here.
|
||||
if (!transport.sessionId) {
|
||||
try { server.close(); } catch { /* ignore */ }
|
||||
try { transport.close(); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,14 +16,18 @@ export interface McpSession {
|
||||
|
||||
export const sessions = new Map<string, McpSession>();
|
||||
|
||||
/** Close both halves of a session and drop it from the map. Close errors are non-fatal:
|
||||
* a transport whose socket is already gone must not block eviction of the map entry. */
|
||||
function closeSession(sid: string, session: McpSession): void {
|
||||
try { session.server.close(); } catch { /* ignore */ }
|
||||
try { session.transport.close(); } catch { /* ignore */ }
|
||||
sessions.delete(sid);
|
||||
}
|
||||
|
||||
/** Terminate all active MCP sessions for a specific user (e.g. on token revocation). */
|
||||
export function revokeUserSessions(userId: number): void {
|
||||
for (const [sid, session] of sessions) {
|
||||
if (session.userId === userId) {
|
||||
try { session.server.close(); } catch { /* ignore */ }
|
||||
try { session.transport.close(); } catch { /* ignore */ }
|
||||
sessions.delete(sid);
|
||||
}
|
||||
if (session.userId === userId) closeSession(sid, session);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,10 +36,31 @@ export function revokeUserSessions(userId: number): void {
|
||||
* sessions are closed, not sessions from other clients for the same user. */
|
||||
export function revokeUserSessionsForClient(userId: number, clientId: string): void {
|
||||
for (const [sid, session] of sessions) {
|
||||
if (session.userId === userId && session.clientId === clientId) {
|
||||
try { session.server.close(); } catch { /* ignore */ }
|
||||
try { session.transport.close(); } catch { /* ignore */ }
|
||||
sessions.delete(sid);
|
||||
}
|
||||
if (session.userId === userId && session.clientId === clientId) closeSession(sid, session);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the least-recently-active session for a user so a new one can take its slot,
|
||||
* and return its id (null when the user has no sessions).
|
||||
*
|
||||
* This is what keeps the per-user cap from becoming a dead end. A client that cannot
|
||||
* persist its Mcp-Session-Id — a proxy stripping the header, a non-conformant client —
|
||||
* re-initializes on every call, and a hard cap would leave the user permanently unable
|
||||
* to open a session until the process restarted. Evicting the coldest session instead
|
||||
* means the worst case is a bounded ring of sessions, not a wedged integration.
|
||||
*/
|
||||
export function evictOldestSessionForUser(userId: number): string | null {
|
||||
let oldestSid: string | null = null;
|
||||
let oldestSession: McpSession | null = null;
|
||||
for (const [sid, session] of sessions) {
|
||||
if (session.userId !== userId) continue;
|
||||
if (!oldestSession || session.lastActivity < oldestSession.lastActivity) {
|
||||
oldestSid = sid;
|
||||
oldestSession = session;
|
||||
}
|
||||
}
|
||||
if (!oldestSid || !oldestSession) return null;
|
||||
closeSession(oldestSid, oldestSession);
|
||||
return oldestSid;
|
||||
}
|
||||
|
||||
@@ -82,6 +82,14 @@ export function applyGlobalMiddleware(
|
||||
// answer OPTIONS without Access-Control-Allow-Origin before the SDK's own cors() runs.
|
||||
// All /.well-known/* paths get open CORS so clients probing openid-configuration or the
|
||||
// RFC 8414 path-suffixed AS metadata form don't get CORS-blocked (they get 404 JSON instead).
|
||||
//
|
||||
// `exposedHeaders` is load-bearing, not cosmetic. Without Access-Control-Expose-Headers the
|
||||
// Fetch spec forbids a browser-context client (Claude Desktop connectors, Claude.ai, MCP
|
||||
// Inspector) from *reading* Mcp-Session-Id off the initialize response — so it can never echo
|
||||
// the header back, every request looks like a fresh initialize, and the server mints a new
|
||||
// session per tool call until the per-user cap wedges the connection. Same reasoning for
|
||||
// WWW-Authenticate, which carries the RFC 9728 resource-metadata challenge that drives
|
||||
// OAuth discovery.
|
||||
app.use(
|
||||
(req: Request, _res: Response, next: NextFunction) => {
|
||||
if (
|
||||
@@ -91,7 +99,11 @@ export function applyGlobalMiddleware(
|
||||
req.path === '/oauth/userinfo' ||
|
||||
req.path === '/mcp'
|
||||
) {
|
||||
cors({ origin: '*', credentials: false })(req, _res, next);
|
||||
cors({
|
||||
origin: '*',
|
||||
credentials: false,
|
||||
exposedHeaders: ['Mcp-Session-Id', 'MCP-Protocol-Version', 'WWW-Authenticate'],
|
||||
})(req, _res, next);
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
|
||||
@@ -303,8 +303,11 @@ export class PluginRpcHost {
|
||||
this.methods.set('trips.getById', (p, uid) =>
|
||||
this.tripRead(p, uid, () => deps.db.prepare('SELECT * FROM trips WHERE id = ?').get(num(p.tripId, 'tripId'))),
|
||||
);
|
||||
// The trip's place POOL — places carry no itinerary position of their own
|
||||
// (day_id/order_index live on day_assignments), so order by created_at like
|
||||
// the REST list does. Use trips.getDays for the day-ordered itinerary.
|
||||
this.methods.set('trips.getPlaces', (p, uid) =>
|
||||
this.tripRead(p, uid, () => deps.db.prepare('SELECT * FROM places WHERE trip_id = ? ORDER BY day_id, position').all(num(p.tripId, 'tripId'))),
|
||||
this.tripRead(p, uid, () => deps.db.prepare('SELECT * FROM places WHERE trip_id = ? ORDER BY created_at DESC').all(num(p.tripId, 'tripId'))),
|
||||
);
|
||||
// Hydrated like the REST list (endpoints, day_positions, joins, normalized
|
||||
// accommodation_id) — a strict superset of the raw row, so older callers
|
||||
|
||||
@@ -67,15 +67,15 @@ function upsert(db: BetterSqlite3.Database, m: PluginManifest): void {
|
||||
if (existing) {
|
||||
db.prepare(
|
||||
`UPDATE plugins SET name = ?, description = ?, type = ?, icon = ?, version = ?, api_version = ?,
|
||||
min_trek_version = ?, permissions = ?, capabilities = ?, dependencies = ?, operator_egress = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
|
||||
).run(m.name, m.description ?? null, m.type, m.icon ?? 'Blocks', m.version, m.apiVersion, m.minTrekVersion ?? null, JSON.stringify(m.permissions), JSON.stringify(m.capabilities), dependencies, m.operatorEgress ? 1 : 0, m.id);
|
||||
min_trek_version = ?, trek_range = ?, permissions = ?, capabilities = ?, dependencies = ?, operator_egress = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
|
||||
).run(m.name, m.description ?? null, m.type, m.icon ?? 'Blocks', m.version, m.apiVersion, m.minTrekVersion ?? null, m.trekRange, JSON.stringify(m.permissions), JSON.stringify(m.capabilities), dependencies, m.operatorEgress ? 1 : 0, m.id);
|
||||
} else {
|
||||
db.prepare(
|
||||
// granted_permissions '' (empty, not '[]') marks "never consented" so the
|
||||
// first activation is distinguishable from a plugin consented to zero perms.
|
||||
`INSERT INTO plugins (id, name, description, type, icon, version, api_version, min_trek_version, permissions, capabilities, dependencies, operator_egress, granted_permissions, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', 'inactive')`,
|
||||
).run(m.id, m.name, m.description ?? null, m.type, m.icon ?? 'Blocks', m.version, m.apiVersion, m.minTrekVersion ?? null, JSON.stringify(m.permissions), JSON.stringify(m.capabilities), dependencies, m.operatorEgress ? 1 : 0);
|
||||
`INSERT INTO plugins (id, name, description, type, icon, version, api_version, min_trek_version, trek_range, permissions, capabilities, dependencies, operator_egress, granted_permissions, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', 'inactive')`,
|
||||
).run(m.id, m.name, m.description ?? null, m.type, m.icon ?? 'Blocks', m.version, m.apiVersion, m.minTrekVersion ?? null, m.trekRange, JSON.stringify(m.permissions), JSON.stringify(m.capabilities), dependencies, m.operatorEgress ? 1 : 0);
|
||||
}
|
||||
|
||||
// Refresh the settings-page action descriptors from the manifest.
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import semver from 'semver';
|
||||
|
||||
/**
|
||||
* Host-version compatibility for plugins (#plugins). A manifest declares the TREK
|
||||
* versions it supports as a semver RANGE (`"trek": ">=3.2.0 <4.0.0"`); this module
|
||||
* is the single place that answers "what TREK is running" and "does that range admit
|
||||
* it". Every install front door (registry, sideload, dev-link) and the activation
|
||||
* gate go through here, so a plugin can never run against a host it says it doesn't
|
||||
* support — and, just as importantly, they all answer the question the same way.
|
||||
*/
|
||||
|
||||
/** The running TREK version (same source as the rest of the app). */
|
||||
export function hostVersion(): string {
|
||||
return process.env.APP_VERSION || (require('../../../../package.json') as { version: string }).version;
|
||||
}
|
||||
|
||||
let warnedUnparseable = false;
|
||||
|
||||
/**
|
||||
* The running version as a plain release triple, or null when it can't be parsed at all.
|
||||
*
|
||||
* Prereleases are deliberately COERCED to their target release: 3.4.0-rc.1 compares as
|
||||
* 3.4.0, and 4.0.0-rc.1 as 4.0.0. Both halves of that matter, and semver's own ordering
|
||||
* gets both wrong for this purpose:
|
||||
*
|
||||
* - Plain `satisfies()` excludes a prerelease from any range that doesn't name one, so
|
||||
* 3.4.0-rc.1 would fail ">=3.2.0 <4.0.0" and EVERY plugin on the instance would go
|
||||
* incompatible the moment TREK shipped a release candidate.
|
||||
* - `includePrerelease` fixes that but orders 4.0.0-rc.1 BEFORE 4.0.0, so it satisfies
|
||||
* "<4.0.0" — letting a plugin that disclaims TREK 4 load on a build that already
|
||||
* carries TREK 4's breaking changes. That is the precise failure this gate exists to
|
||||
* prevent.
|
||||
*
|
||||
* Coercing to the release says the useful thing instead: for compatibility purposes, an
|
||||
* rc of 4.0 IS 4.0.
|
||||
*
|
||||
* A null host NEVER blocks anything (see {@link hostSatisfies}). This is deliberate:
|
||||
* APP_VERSION is a Docker build ARG that defaults to the literal string `dev`, and an
|
||||
* unversioned build must stay fully usable. The warning exists so that a MISCONFIGURED
|
||||
* APP_VERSION in production — which silently switches this whole gate off — is visible
|
||||
* in the logs instead of being discovered when an incompatible plugin misbehaves.
|
||||
*/
|
||||
export function normalizedHost(): string | null {
|
||||
const raw = hostVersion();
|
||||
const coerced = semver.coerce(raw)?.version ?? null;
|
||||
if (!coerced && !warnedUnparseable) {
|
||||
warnedUnparseable = true;
|
||||
console.warn(`[plugins] APP_VERSION "${raw}" is not a semver version — plugin TREK-compatibility checks are disabled`);
|
||||
}
|
||||
return coerced;
|
||||
}
|
||||
|
||||
/** Test hook: re-arm the one-time unparseable-host warning. */
|
||||
export function __resetHostWarningForTests(): void {
|
||||
warnedUnparseable = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `r` is a semver range a plugin may declare.
|
||||
*
|
||||
* `semver.validRange` alone is not enough: ">=4.0.0 <3.0.0" is a VALID range that no
|
||||
* version can ever satisfy, and a plugin that declares it would be uninstallable with
|
||||
* no way to tell the author why. minVersion() returns null for exactly that case (and
|
||||
* throws outright on garbage like "latest"), so it is the real satisfiability test.
|
||||
*/
|
||||
export function isValidTrekRange(r: unknown): r is string {
|
||||
if (typeof r !== 'string' || !r.trim()) return false;
|
||||
if (semver.validRange(r) === null) return false;
|
||||
try {
|
||||
return semver.minVersion(r) !== null;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The lowest TREK version a range admits — what the UI shows as "Requires TREK x+".
|
||||
* Undefined for a range with no real lower bound (`*`, `<4.0.0`), because "requires
|
||||
* TREK 0.0.0+" is noise rather than information.
|
||||
*/
|
||||
export function minTrekOf(range: string): string | undefined {
|
||||
if (!isValidTrekRange(range)) return undefined;
|
||||
const min = semver.minVersion(range);
|
||||
if (!min || min.version === '0.0.0') return undefined;
|
||||
return min.version;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the running TREK satisfies `range`. The host arrives here already coerced to a
|
||||
* release triple (see {@link normalizedHost}), which is what makes a plain satisfies()
|
||||
* correct on both prerelease edges.
|
||||
*
|
||||
* A null/empty range is not a decision this function can make — the caller knows whether
|
||||
* an undeclared range means "reject" (an install) or "unknown" (an already-installed
|
||||
* plugin), and they surface different codes. It returns false so neither can pass by
|
||||
* accident.
|
||||
*/
|
||||
export function hostSatisfies(range: string | null | undefined, host: string | null = normalizedHost()): boolean {
|
||||
// Coerce again rather than trusting the argument: callers that pass a host explicitly
|
||||
// (the registry, tests) would otherwise skip the prerelease normalisation and get a
|
||||
// different answer than the default path for the same TREK.
|
||||
const release = host === null ? null : (semver.coerce(host)?.version ?? null);
|
||||
if (release === null) return true; // unversioned build — never block
|
||||
if (!isValidTrekRange(range)) return false;
|
||||
return semver.satisfies(release, range);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import semver from 'semver';
|
||||
import { isKnownPermission } from '../protocol/envelope';
|
||||
import { isValidTrekRange, minTrekOf } from './host-compat';
|
||||
import type { NotifEventType } from '../../../services/notificationPreferencesService';
|
||||
|
||||
/**
|
||||
@@ -87,7 +88,11 @@ export interface PluginManifest {
|
||||
homepage?: string;
|
||||
icon?: string;
|
||||
type: 'integration' | 'page' | 'widget' | 'trip-page';
|
||||
/** The raw semver RANGE of TREK versions this plugin supports (">=3.2.0 <4.0.0"). */
|
||||
trek?: string;
|
||||
/** Same range, normalized: a valid+satisfiable one, or null. What the host gates on. */
|
||||
trekRange: string | null;
|
||||
/** The range's lower bound, for display ("Requires TREK 3.2.0+"). Derived, never authored. */
|
||||
minTrekVersion?: string;
|
||||
nativeModules: boolean;
|
||||
permissions: string[];
|
||||
@@ -134,7 +139,19 @@ export function parseJsonText(text: string): unknown {
|
||||
return JSON.parse(text.charCodeAt(0) === 0xfeff ? text.slice(1) : text);
|
||||
}
|
||||
|
||||
export function parseManifest(raw: unknown): PluginManifest {
|
||||
/**
|
||||
* Validate a manifest.
|
||||
*
|
||||
* `requireTrek` is the INSTALL front doors (registry, sideload, dev-link): a plugin
|
||||
* that doesn't say which TREK versions it supports may not be installed at all.
|
||||
*
|
||||
* It is deliberately OFF for discovery, which is a reconciler, not a gate. Discovery's
|
||||
* failure path only logs and skips — it never touches the plugins row — so a plugin it
|
||||
* rejects keeps its stale `enabled = 1` and gets spawned by the next boot anyway. Making
|
||||
* this strict there would produce a plugin that is invisible to discovery and still
|
||||
* running. Activation is where an undeclared range is actually refused.
|
||||
*/
|
||||
export function parseManifest(raw: unknown, opts?: { requireTrek?: boolean }): PluginManifest {
|
||||
if (!raw || typeof raw !== 'object') throw new ManifestError('manifest is not an object');
|
||||
const m = raw as Record<string, unknown>;
|
||||
|
||||
@@ -183,6 +200,14 @@ export function parseManifest(raw: unknown): PluginManifest {
|
||||
if (badEgress !== undefined) throw new ManifestError(`invalid egress host "${badEgress}"`);
|
||||
|
||||
const trek = optStr(m.trek);
|
||||
const trekRange = isValidTrekRange(trek) ? trek : null;
|
||||
if (opts?.requireTrek && !trekRange) {
|
||||
throw new ManifestError(
|
||||
trek
|
||||
? `invalid "trek" version range "${trek}" (expected a satisfiable semver range, e.g. ">=3.2.0 <4.0.0")`
|
||||
: 'missing "trek" version range — declare the TREK versions this plugin supports, e.g. ">=3.2.0 <4.0.0"',
|
||||
);
|
||||
}
|
||||
return {
|
||||
id,
|
||||
name: str(m.name, 'name'),
|
||||
@@ -194,7 +219,11 @@ export function parseManifest(raw: unknown): PluginManifest {
|
||||
icon: optStr(m.icon) ?? 'Blocks',
|
||||
type: type as PluginManifest['type'],
|
||||
trek,
|
||||
minTrekVersion: trek ? (trek.match(/(\d+\.\d+\.\d+)/)?.[1] ?? undefined) : undefined,
|
||||
trekRange,
|
||||
// Derived from the range itself, never from the first number that looks like a
|
||||
// version in it: a range of "<4.0.0" has a *first* semver of 4.0.0 but a lower
|
||||
// bound of 0.0.0, so reading it off the text produced the exact inverse of the truth.
|
||||
minTrekVersion: trekRange ? minTrekOf(trekRange) : undefined,
|
||||
nativeModules: false,
|
||||
permissions,
|
||||
egress,
|
||||
|
||||
@@ -119,7 +119,6 @@ export function pluginPermissionArgs(pluginId: string): string[] {
|
||||
// off by accident.
|
||||
if (!warnedPermissionsOff) {
|
||||
warnedPermissionsOff = true;
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[plugins] TREK_PLUGIN_PERMISSIONS=off — the OS permission jail is DISABLED; installed plugins run with full Node access to this process. Only use this on a machine you fully trust.');
|
||||
}
|
||||
return [];
|
||||
|
||||
@@ -111,7 +111,6 @@ export function applyStagedPluginTrees(): string[] {
|
||||
swapContents(live, staged);
|
||||
applied.push(label);
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`[plugins] failed to apply staged ${label} restore:`, err);
|
||||
}
|
||||
}
|
||||
@@ -135,7 +134,6 @@ export async function applyStagedRestoreNow(): Promise<boolean> {
|
||||
await applier();
|
||||
return true;
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('[plugins] immediate staged-restore apply failed; will retry on next boot:', err);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -67,12 +67,20 @@ export class PluginFrameController {
|
||||
|
||||
/** Per-plugin, locked-down CSP for the sandboxed frame document. */
|
||||
private frameCsp(pluginId: string, host: string | undefined): string {
|
||||
// Defense in depth: the manifest validator already constrains these hosts, but
|
||||
// never interpolate anything that isn't a clean host/wildcard into connect-src
|
||||
// (a stray space or `*` would inject an extra CSP source token).
|
||||
const outbound = this.runtime
|
||||
.outboundHostsOf(pluginId)
|
||||
.filter((h) => /^(\*\.[a-z0-9-]+(\.[a-z0-9-]+)+|[a-z0-9-]+(\.[a-z0-9-]+)*)$/i.test(h));
|
||||
// The frame must be able to reach exactly what the CHILD may reach. The child's egress
|
||||
// guard is the union of the manifest's http:outbound:<host> grants AND the hosts an admin
|
||||
// added post-install for an operatorEgress plugin (plugin-runtime.service: activate()).
|
||||
// Building connect-src from the grants alone left an operatorEgress plugin WITH A UI able
|
||||
// to call the operator's host from its server but CSP-blocked in its own iframe. The admin
|
||||
// consented to these hosts at install and the child already reaches them, so matching the
|
||||
// frame to the child widens no trust boundary that isn't already crossed.
|
||||
//
|
||||
// Defense in depth: both sources are validated upstream (the manifest validator; the
|
||||
// admin writer's EGRESS_HOST_RE), but never interpolate anything that isn't a clean
|
||||
// host/wildcard into connect-src — a stray space or `*` would inject an extra CSP source.
|
||||
const outbound = [
|
||||
...new Set([...this.runtime.outboundHostsOf(pluginId), ...this.runtime.operatorEgressHosts(pluginId)]),
|
||||
].filter((h) => /^(\*\.[a-z0-9-]+(\.[a-z0-9-]+)+|[a-z0-9-]+(\.[a-z0-9-]+)*)$/i.test(h));
|
||||
// The frame runs at an OPAQUE origin (sandbox without allow-same-origin), so
|
||||
// 'self' matches nothing and the plugin's own <script src>/<link> files would
|
||||
// be blocked — a multi-file client build (Vite/React output) only worked
|
||||
|
||||
@@ -22,7 +22,10 @@ import { parseJsonText, parseManifest } from './install/manifest';
|
||||
import { scanForNativeBinaries } from './install/native-scan';
|
||||
import { devLinkEnabled, DEV_LINK_SOURCE } from './dev-link';
|
||||
import { pluginCodeDir, pluginDataDir } from './paths';
|
||||
import { PluginRegistryService } from './registry/registry.service';
|
||||
import { assertHostCompatible, PluginRegistryService, RegistryError } from './registry/registry.service';
|
||||
import { hostSatisfies, hostVersion } from './install/host-compat';
|
||||
import { keyFingerprint } from './signature-status';
|
||||
import { writeAudit } from '../../services/auditLog';
|
||||
import { isAddonEnabled } from '../../services/adminService';
|
||||
import type { PluginDependency } from './install/manifest';
|
||||
import type { VersionMismatch, PluginDepRow } from './dependencies';
|
||||
@@ -83,18 +86,31 @@ export class PluginConsentRequired extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export type PluginDependencyCode = 'ADDON_DISABLED' | 'DEPENDENCY_MISSING';
|
||||
export type PluginDependencyCode =
|
||||
| 'ADDON_DISABLED'
|
||||
| 'DEPENDENCY_MISSING'
|
||||
/** The plugin's declared TREK range doesn't admit the running host. */
|
||||
| 'TREK_VERSION_INCOMPATIBLE'
|
||||
/** The plugin never declared a range, so we can't know that it does. */
|
||||
| 'TREK_VERSION_UNKNOWN';
|
||||
|
||||
/**
|
||||
* Thrown when a plugin can't activate because a required addon is disabled or a
|
||||
* declared plugin dependency is missing / version-mismatched. The controller maps
|
||||
* it to a 409 carrying `code` + `detail` so the admin UI can offer the right fix.
|
||||
* Thrown when a plugin can't activate because a required addon is disabled, a declared
|
||||
* plugin dependency is missing / version-mismatched, or it doesn't support this TREK.
|
||||
* The controller maps it to a 409 carrying `code` + `detail` so the admin UI can offer
|
||||
* the right fix.
|
||||
*
|
||||
* Host-incompatibility deliberately reuses this class rather than introducing its own.
|
||||
* The boot loop only reconciles a failed activation's row back to `enabled = 0` for a
|
||||
* PluginDependencyError / DependencyCycleError; any other error falls through to a branch
|
||||
* that relies on the supervisor persisting a status — and the supervisor never ran. A
|
||||
* separate class would therefore leave a plugin that never started still reading "active".
|
||||
*/
|
||||
export class PluginDependencyError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly code: PluginDependencyCode,
|
||||
readonly detail: { addons?: string[]; missing?: PluginDependency[]; versionMismatch?: VersionMismatch[] } = {},
|
||||
readonly detail: { addons?: string[]; missing?: PluginDependency[]; versionMismatch?: VersionMismatch[]; trekRange?: string | null; hostVersion?: string } = {},
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'PluginDependencyError';
|
||||
@@ -447,15 +463,35 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
/**
|
||||
* Read-only activation gate for one plugin — throws (without mutating) if it may
|
||||
* not activate. Checks run most- to least-severe: permission re-consent →
|
||||
* required addon disabled → missing/mismatched plugin dependency.
|
||||
* not activate. Checks run most- to least-severe: TREK-version compatibility →
|
||||
* permission re-consent → required addon disabled → missing/mismatched plugin dependency.
|
||||
*/
|
||||
private assertActivatable(id: string, installed: Map<string, PluginDepRow>, consentWiden: boolean): void {
|
||||
const row = db.prepare('SELECT permissions, granted_permissions, dependencies FROM plugins WHERE id = ?').get(id) as
|
||||
| { permissions: string; granted_permissions: string; dependencies: string | null }
|
||||
const row = db.prepare('SELECT permissions, granted_permissions, dependencies, trek_range FROM plugins WHERE id = ?').get(id) as
|
||||
| { permissions: string; granted_permissions: string; dependencies: string | null; trek_range: string | null }
|
||||
| undefined;
|
||||
if (!row) throw new Error(`plugin ${id} not found`);
|
||||
|
||||
// This runs FIRST, and before the consent check in particular: a plugin that cannot
|
||||
// run on this TREK must never be offered a permission dialog, because consenting to
|
||||
// it would not make the plugin start. It is also the gate that catches the case
|
||||
// install can't — TREK was upgraded PAST the plugin's declared upper bound, so code
|
||||
// that was legitimately installed no longer supports the host it's sitting on.
|
||||
if (!row.trek_range) {
|
||||
throw new PluginDependencyError(
|
||||
`plugin ${id} does not declare which TREK versions it supports`,
|
||||
'TREK_VERSION_UNKNOWN',
|
||||
{ trekRange: null, hostVersion: hostVersion() },
|
||||
);
|
||||
}
|
||||
if (!hostSatisfies(row.trek_range)) {
|
||||
throw new PluginDependencyError(
|
||||
`plugin ${id} requires TREK ${row.trek_range} — this is TREK ${hostVersion()}`,
|
||||
'TREK_VERSION_INCOMPATIBLE',
|
||||
{ trekRange: row.trek_range, hostVersion: hostVersion() },
|
||||
);
|
||||
}
|
||||
|
||||
const declared = parseArray(row.permissions).filter(isKnownPermission);
|
||||
const granted = parseArray(row.granted_permissions);
|
||||
const newGrants = declared.filter((p) => !granted.includes(p));
|
||||
@@ -586,16 +622,24 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy {
|
||||
* Install runs first so a failed download/signature/integrity check leaves the
|
||||
* currently-running child untouched (it keeps serving the old code from memory).
|
||||
*/
|
||||
async update(id: string): Promise<{ version: string; activated: boolean; newPermissions: string[]; newEgress: string[] }> {
|
||||
const before = db.prepare('SELECT enabled, granted_permissions FROM plugins WHERE id = ?').get(id) as
|
||||
| { enabled: number; granted_permissions: string }
|
||||
async update(id: string, opts?: { version?: string; retrustKey?: string }): Promise<{ version: string; activated: boolean; newPermissions: string[]; newEgress: string[] }> {
|
||||
const before = db.prepare('SELECT enabled, granted_permissions, version FROM plugins WHERE id = ?').get(id) as
|
||||
| { enabled: number; granted_permissions: string; version: string | null }
|
||||
| undefined;
|
||||
if (!before) throw new Error(`plugin ${id} not found`);
|
||||
if (!this.registry) throw new Error('registry service unavailable');
|
||||
const wasEnabled = before.enabled === 1;
|
||||
const granted = new Set(parseArray(before.granted_permissions));
|
||||
|
||||
const res = await this.registry.install(id); // swaps code + refreshes declared permissions; keeps granted
|
||||
// Pick the newest version that this TREK can actually run, not simply the newest one
|
||||
// published. Taking the latest blindly is how an update BREAKS a working plugin: the
|
||||
// new release drops support for this host, the swap succeeds, and the activation gate
|
||||
// then refuses to restart it — leaving the admin worse off than not updating. Refusing
|
||||
// is the honest outcome; the plugin keeps running on the code it has.
|
||||
const target = opts?.version ?? (await this.resolveUpdateTarget(id, before.version));
|
||||
|
||||
// swaps code + refreshes declared permissions; keeps granted
|
||||
const res = await this.registry.install(id, { version: target, retrustKey: opts?.retrustKey });
|
||||
|
||||
const declared = parseArray(
|
||||
(db.prepare('SELECT permissions FROM plugins WHERE id = ?').get(id) as { permissions: string }).permissions,
|
||||
@@ -614,6 +658,72 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy {
|
||||
return { version: res.version, activated: false, newPermissions, newEgress };
|
||||
}
|
||||
|
||||
/**
|
||||
* The version an "update" should install: the newest one compatible with this TREK.
|
||||
* Refuses when that is not actually newer than what's installed — a compatible-but-older
|
||||
* artifact is a DOWNGRADE, and silently rolling a plugin back under the word "update"
|
||||
* would be worse than doing nothing.
|
||||
*/
|
||||
private async resolveUpdateTarget(id: string, installedVersion: string | null): Promise<string> {
|
||||
if (!this.registry) throw new Error('registry service unavailable');
|
||||
const target = await this.registry.resolveVersion(id); // throws TREK_VERSION_INCOMPATIBLE if nothing fits
|
||||
if (installedVersion && semver.valid(installedVersion) && !semver.gt(target.version, installedVersion)) {
|
||||
throw new RegistryError(
|
||||
`no update available for ${id} on TREK ${hostVersion()} — ${installedVersion} is already the newest compatible version`,
|
||||
'NO_COMPATIBLE_UPDATE',
|
||||
);
|
||||
}
|
||||
return target.version;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-trust a plugin whose author signing key ROTATED, and update it — in ONE call.
|
||||
*
|
||||
* The single call is the point. A re-pin that returns and waits for the client to
|
||||
* then call /update leaves a window where `author_pubkey` is pinned to a key no
|
||||
* install has ever been verified against; if that second call never arrives (the
|
||||
* admin closes the tab), the plugin sits pinned to an unverified key. So: either the
|
||||
* new key verifies the artifact and the plugin lands on the new version with the new
|
||||
* key pinned, or nothing changes at all.
|
||||
*
|
||||
* The safety properties live below this call, not here:
|
||||
* - assertRetrustable re-derives the condition server-side, so this refuses anything
|
||||
* other than a genuinely changed key (an invalid signature is NOT overridable),
|
||||
* and refuses a key that changed again since the admin was shown it.
|
||||
* - install() still verifies the artifact under the new key before pinning it, and
|
||||
* only ever pins a key — never clears one to NULL.
|
||||
*/
|
||||
async retrust(
|
||||
id: string,
|
||||
version: string,
|
||||
publicKey: string,
|
||||
actor: { userId: number | null; ip?: string | null },
|
||||
): Promise<{ version: string; activated: boolean; newPermissions: string[]; newEgress: string[] }> {
|
||||
if (!this.registry) throw new Error('registry service unavailable');
|
||||
const before = db.prepare('SELECT author_pubkey FROM plugins WHERE id = ?').get(id) as { author_pubkey?: string | null } | undefined;
|
||||
const entry = await this.registry.assertRetrustable(id, publicKey); // throws unless SIGNATURE_KEY_CHANGED
|
||||
|
||||
const res = await this.update(id, { version, retrustKey: publicKey });
|
||||
|
||||
// Audited because re-trusting a signing key is precisely the event that has to be
|
||||
// reconstructible after an incident. This goes to the ADMIN audit log, not the
|
||||
// plugin capability log — that one answers "what have plugins done in my name?"
|
||||
// and is shown to end users; a lifecycle action by an admin does not belong there.
|
||||
writeAudit({
|
||||
userId: actor.userId,
|
||||
action: 'admin.plugin_retrust',
|
||||
resource: id,
|
||||
details: {
|
||||
plugin: id,
|
||||
version: res.version,
|
||||
oldKeyFingerprint: keyFingerprint(before?.author_pubkey),
|
||||
newKeyFingerprint: keyFingerprint(entry.authorPublicKey),
|
||||
},
|
||||
ip: actor.ip ?? null,
|
||||
});
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sideload a plugin from an uploaded archive (admin "Upload plugin"). Extracts +
|
||||
* validates into staging first, stops any running child of the same id (its code
|
||||
@@ -654,7 +764,8 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy {
|
||||
if (!path.isAbsolute(sourceDir)) throw new Error('the dev-link path must be absolute');
|
||||
const manifestPath = path.join(sourceDir, 'trek-plugin.json');
|
||||
if (!fs.existsSync(manifestPath)) throw new Error(`no trek-plugin.json at ${sourceDir}`);
|
||||
const manifest = parseManifest(parseJsonText(fs.readFileSync(manifestPath, 'utf8')));
|
||||
const manifest = parseManifest(parseJsonText(fs.readFileSync(manifestPath, 'utf8')), { requireTrek: true });
|
||||
assertHostCompatible(manifest.trekRange, manifest.id);
|
||||
if (!fs.existsSync(path.join(sourceDir, 'server', 'index.js'))) {
|
||||
throw new Error('no built server/index.js — build the plugin first (the loader runs the compiled artifact, not TS source)');
|
||||
}
|
||||
@@ -675,8 +786,13 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy {
|
||||
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
||||
fs.symlinkSync(sourceDir, dest, 'junction'); // Windows junction (no elevation); POSIX ignores the type -> dir symlink
|
||||
discoverPlugins(db); // registers/updates the row from the linked manifest, INACTIVE
|
||||
// Same as a sideload: the plugin has left the registry trust model, so a block that
|
||||
// described a refused REGISTRY update no longer describes the code that will run.
|
||||
db.prepare(
|
||||
`UPDATE plugins SET source_repo = ?, source_commit = NULL, sha256 = NULL, author_pubkey = NULL, status = 'inactive', enabled = 0 WHERE id = ?`,
|
||||
`UPDATE plugins SET source_repo = ?, source_commit = NULL, sha256 = NULL, author_pubkey = NULL,
|
||||
update_block_code = NULL, update_block_detail = NULL, update_block_version = NULL,
|
||||
status = 'inactive', enabled = 0
|
||||
WHERE id = ?`,
|
||||
).run(DEV_LINK_SOURCE, id);
|
||||
this.watchLinked(id, sourceDir);
|
||||
return { id, version: manifest.version, replaced };
|
||||
|
||||
@@ -1,14 +1,37 @@
|
||||
import { Body, Controller, Delete, Get, HttpCode, HttpException, Param, Post, Put, Query, UploadedFile, UseGuards, UseInterceptors } from '@nestjs/common';
|
||||
import { Body, Controller, Delete, Get, HttpCode, HttpException, Param, Post, Put, Query, Req, UploadedFile, UseGuards, UseInterceptors } from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import type { Request } from 'express';
|
||||
import { PluginsService } from './plugins.service';
|
||||
import { PluginRuntimeService, PluginConsentRequired, PluginDependencyError } from './plugin-runtime.service';
|
||||
import { DependencyCycleError } from './dependencies';
|
||||
import { PluginRegistryService } from './registry/registry.service';
|
||||
import { PluginRegistryService, RegistryError } from './registry/registry.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { AdminGuard } from '../auth/admin.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { getClientIp } from '../../services/auditLog';
|
||||
import { pluginsEnabled } from './kill-switch';
|
||||
import { devLinkEnabled } from './dev-link';
|
||||
|
||||
/**
|
||||
* Flatten a registry/install failure into the error envelope — CARRYING THE CODE.
|
||||
*
|
||||
* The code is the whole point: the admin UI offers the re-trust override only for
|
||||
* SIGNATURE_KEY_CHANGED, and it must read that from a field, not by string-matching the
|
||||
* message. Drop the code here and the client is left guessing from prose, which sooner
|
||||
* or later means offering "re-trust" on a signature that simply doesn't verify.
|
||||
* TrekExceptionFilter passes a `{ error, code }` body through verbatim.
|
||||
*
|
||||
* A missing plugin is a 404, not a 400 — the request was well-formed, the thing just
|
||||
* isn't there. Only `assertRetrustable` raises NOT_FOUND, so this is scoped to /retrust:
|
||||
* install's "not in registry" carries no code and update throws a plain Error, and both
|
||||
* keep their existing 400.
|
||||
*/
|
||||
function registryFailure(e: unknown, fallback: string): HttpException {
|
||||
const code = e instanceof RegistryError ? e.code : undefined;
|
||||
const error = e instanceof Error ? e.message : fallback;
|
||||
return new HttpException(code ? { error, code } : { error }, code === 'NOT_FOUND' ? 404 : 400);
|
||||
}
|
||||
|
||||
/**
|
||||
* /api/admin/plugins — admin-only plugin control surface (#plugins).
|
||||
*
|
||||
@@ -57,7 +80,7 @@ export class PluginsController {
|
||||
if (body.withDependencies) return await this.registry.installWithDependencies(body.id, body.constraint);
|
||||
return await this.registry.install(body.id, { version: body.version, constraint: body.constraint });
|
||||
} catch (e) {
|
||||
throw new HttpException({ error: e instanceof Error ? e.message : 'install failed' }, 400);
|
||||
throw registryFailure(e, 'install failed');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,7 +94,7 @@ export class PluginsController {
|
||||
try {
|
||||
return await this.runtime.sideload(file.buffer);
|
||||
} catch (e) {
|
||||
throw new HttpException({ error: e instanceof Error ? e.message : 'upload failed' }, 400);
|
||||
throw registryFailure(e, 'upload failed'); // carries TREK_VERSION_INCOMPATIBLE
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,7 +112,7 @@ export class PluginsController {
|
||||
try {
|
||||
return await this.runtime.link(dir);
|
||||
} catch (e) {
|
||||
throw new HttpException({ error: e instanceof Error ? e.message : 'link failed' }, 400);
|
||||
throw registryFailure(e, 'link failed'); // carries TREK_VERSION_INCOMPATIBLE
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,7 +211,37 @@ export class PluginsController {
|
||||
try {
|
||||
return await this.runtime.update(id);
|
||||
} catch (e) {
|
||||
throw new HttpException({ error: e instanceof Error ? e.message : 'update failed' }, 400);
|
||||
throw registryFailure(e, 'update failed');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-trust a plugin whose author signing key ROTATED, and update it in the same call.
|
||||
*
|
||||
* This is the ONLY override of a signature refusal that exists, and it exists only for
|
||||
* a changed key: a rotation has a benign explanation, a signature that doesn't verify
|
||||
* does not. The scoping is enforced in the service (`assertRetrustable`), not by this
|
||||
* route and not by the UI — calling this directly on a plugin with an INVALID signature
|
||||
* is refused.
|
||||
*
|
||||
* `publicKey` is the full key the admin was SHOWN, echoed back so the server can refuse
|
||||
* if the registry entry has been re-keyed again since the dialog rendered.
|
||||
*/
|
||||
@Post(':id/retrust')
|
||||
@HttpCode(200)
|
||||
async retrust(
|
||||
@Param('id') id: string,
|
||||
@Body() body: { version?: string; publicKey?: string },
|
||||
@CurrentUser() user: { id: number },
|
||||
@Req() req: Request,
|
||||
) {
|
||||
if (!pluginsEnabled()) throw new HttpException({ error: 'Plugins are disabled by server configuration' }, 503);
|
||||
if (!body?.version) throw new HttpException({ error: 'version is required' }, 400);
|
||||
if (!body?.publicKey) throw new HttpException({ error: 'publicKey is required' }, 400);
|
||||
try {
|
||||
return await this.runtime.retrust(id, body.version, body.publicKey, { userId: user?.id ?? null, ip: getClientIp(req) });
|
||||
} catch (e) {
|
||||
throw registryFailure(e, 'retrust failed');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,14 +4,16 @@ import { pluginsEnabled } from './kill-switch';
|
||||
import { devLinkEnabled } from './dev-link';
|
||||
import { maybe_encrypt_api_key, decrypt_api_key } from '../../services/apiKeyCrypto';
|
||||
import { readAudit } from './host/plugin-audit';
|
||||
import { keyFingerprint } from './signature-status';
|
||||
import { pluginBudgetUsage } from './host/create-rpc-host';
|
||||
import { isAddonEnabled } from '../../services/adminService';
|
||||
import { parseDependencies, disabledRequiredAddons, resolveDependencyState, type PluginDepRow, type PluginDependencies, type VersionMismatch } from './dependencies';
|
||||
import { hostSatisfies, hostVersion } from './install/host-compat';
|
||||
import type { PluginDependency } from './install/manifest';
|
||||
|
||||
const SECRET_MASK = '••••••••';
|
||||
|
||||
export type PluginDependencyStatus = 'ok' | 'addonDisabled' | 'missingPlugin';
|
||||
export type PluginDependencyStatus = 'ok' | 'addonDisabled' | 'missingPlugin' | 'hostIncompatible';
|
||||
|
||||
/**
|
||||
* Read side of the plugin system (#plugins), M0 scaffold. Lists installed
|
||||
@@ -35,6 +37,11 @@ interface PluginRawRow {
|
||||
permissions: string;
|
||||
capabilities: string;
|
||||
dependencies: string | null;
|
||||
trek_range: string | null;
|
||||
author_pubkey: string | null;
|
||||
update_block_code: string | null;
|
||||
update_block_detail: string | null;
|
||||
update_block_version: string | null;
|
||||
}
|
||||
|
||||
/** Hosts an admin has added for a plugin (0 unless it declared operatorEgress). */
|
||||
@@ -70,8 +77,24 @@ export interface PluginListItem {
|
||||
dependencies: PluginDependencies;
|
||||
/** Whether this plugin can currently activate, and why not if it can't. */
|
||||
dependencyStatus: PluginDependencyStatus;
|
||||
/** The TREK range the plugin declares it supports; null if it declared none. */
|
||||
trekRange: string | null;
|
||||
/** The running TREK, so the UI can say "needs X, you have Y" without doing semver. */
|
||||
hostVersion: string;
|
||||
/** The concrete blockers, so the UI can render chips + the resolve dialog. */
|
||||
dependencyIssues: { disabledAddons: string[]; missing: PluginDependency[]; versionMismatch: VersionMismatch[] };
|
||||
/**
|
||||
* The author's signature was verified and their key TOFU-pinned at install.
|
||||
* False means the bytes matched the registry's sha256 pin and nothing more — one
|
||||
* fewer guarantee, not "insecure". Meaningless for a sideloaded/dev-linked plugin,
|
||||
* which sits outside the registry trust model entirely; the UI badges those instead.
|
||||
*/
|
||||
signed: boolean;
|
||||
/** Short, human-comparable form of the pinned key — for reading out to the author. */
|
||||
keyFingerprint: string | null;
|
||||
/** Why an update was refused, if one was. `version` is the registry version that was
|
||||
* refused, so a caller can treat the block as stale once a newer one is on offer. */
|
||||
updateBlock: { code: string; detail: string | null; version: string | null } | null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@@ -80,7 +103,8 @@ export class PluginsService {
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT id, name, description, type, icon, version, status, enabled, last_error, reviewed_at, source_repo,
|
||||
permissions, capabilities, dependencies, operator_egress
|
||||
permissions, capabilities, dependencies, operator_egress, trek_range,
|
||||
author_pubkey, update_block_code, update_block_detail, update_block_version
|
||||
FROM plugins
|
||||
ORDER BY sort_order, name`,
|
||||
)
|
||||
@@ -92,19 +116,39 @@ export class PluginsService {
|
||||
const deps = parseDependencies(r.dependencies);
|
||||
const disabledAddons = disabledRequiredAddons(deps, isAddonEnabled);
|
||||
const state = resolveDependencyState(deps, installed);
|
||||
const dependencyStatus: PluginDependencyStatus = disabledAddons.length
|
||||
? 'addonDisabled'
|
||||
: state.missing.length || state.versionMismatch.length
|
||||
? 'missingPlugin'
|
||||
: 'ok';
|
||||
const { dependencies: _raw, operator_egress: _oe, ...rest } = r as PluginRawRow & { operator_egress?: number };
|
||||
// Mirrors the order of assertActivatable's gate, so the card explains the same
|
||||
// blocker the activate call would hit rather than a second, lesser one.
|
||||
const dependencyStatus: PluginDependencyStatus = !hostSatisfies(r.trek_range)
|
||||
? 'hostIncompatible'
|
||||
: disabledAddons.length
|
||||
? 'addonDisabled'
|
||||
: state.missing.length || state.versionMismatch.length
|
||||
? 'missingPlugin'
|
||||
: 'ok';
|
||||
const {
|
||||
dependencies: _raw,
|
||||
operator_egress: _oe,
|
||||
trek_range,
|
||||
author_pubkey,
|
||||
update_block_code,
|
||||
update_block_detail,
|
||||
update_block_version,
|
||||
...rest
|
||||
} = r as PluginRawRow & { operator_egress?: number };
|
||||
return {
|
||||
...rest,
|
||||
operatorEgress: _oe === 1,
|
||||
egressHostCount: egressHostCount(r.id),
|
||||
dependencies: deps,
|
||||
dependencyStatus,
|
||||
trekRange: trek_range,
|
||||
hostVersion: hostVersion(),
|
||||
dependencyIssues: { disabledAddons, missing: state.missing, versionMismatch: state.versionMismatch },
|
||||
signed: !!author_pubkey,
|
||||
keyFingerprint: keyFingerprint(author_pubkey),
|
||||
updateBlock: update_block_code
|
||||
? { code: update_block_code, detail: update_block_detail, version: update_block_version }
|
||||
: null,
|
||||
};
|
||||
});
|
||||
return { enabled: pluginsEnabled(), devLink: devLinkEnabled(), plugins };
|
||||
|
||||
@@ -6,10 +6,12 @@ import { db } from '../../../db/database';
|
||||
import type { PluginDependency } from '../install/manifest';
|
||||
import { pluginCodeDir, pluginsCodeRoot, pluginsDataRoot } from '../paths';
|
||||
import { safeDownload, sha256Matches } from '../install/safe-fetch';
|
||||
import { verifyAuthorSignature } from '../install/verify-signature';
|
||||
import { verifyAuthorSignature, SignatureError } from '../install/verify-signature';
|
||||
import { clearUpdateBlock, isSignatureCode, setUpdateBlock, RETRUSTABLE_CODE } from '../signature-status';
|
||||
import { extractArchive } from '../install/safe-extract';
|
||||
import { scanForNativeBinaries } from '../install/native-scan';
|
||||
import { parseJsonText, parseManifest } from '../install/manifest';
|
||||
import { hostSatisfies, hostVersion, normalizedHost } from '../install/host-compat';
|
||||
import { discoverPlugins } from '../install/discovery';
|
||||
|
||||
/**
|
||||
@@ -35,7 +37,16 @@ interface RegistryVersion {
|
||||
commitSha: string;
|
||||
downloadUrl: string;
|
||||
sha256: string;
|
||||
minTrekVersion: string;
|
||||
/**
|
||||
* The version's declared TREK range, mirroring its manifest's `trek`. Authoritative
|
||||
* when present. `minTrekVersion`/`maxTrekVersion` are the legacy shape — a lower bound
|
||||
* plus an optional INCLUSIVE upper bound, which cannot express the exclusive `<4.0.0`
|
||||
* that a manifest actually declares — and stay only so entries published before this
|
||||
* field existed still gate.
|
||||
*/
|
||||
trek?: string;
|
||||
/** Nullable: an entry may carry no lower bound at all (and a legacy one carries only this). */
|
||||
minTrekVersion?: string | null;
|
||||
maxTrekVersion?: string | null;
|
||||
size?: number;
|
||||
apiVersion?: number;
|
||||
@@ -70,6 +81,17 @@ interface Registry {
|
||||
plugins: RegistryEntry[];
|
||||
}
|
||||
|
||||
/** A registry entry's standing against the running TREK, resolved server-side. */
|
||||
export interface HostCompat {
|
||||
/** The latest version's declared TREK range, or null for a legacy (min/max-only) entry. */
|
||||
trek: string | null;
|
||||
hostVersion: string;
|
||||
/** Whether the LATEST version can be installed on this TREK. */
|
||||
compatible: boolean;
|
||||
/** Newest installable version — the latest one, or an older fallback, or null if none fits. */
|
||||
latestCompatible: string | null;
|
||||
}
|
||||
|
||||
/** Anzeige-only preview of a plugin's live manifest (fetched at the reviewed commit). */
|
||||
export interface ManifestPreview {
|
||||
permissions: string[];
|
||||
@@ -97,7 +119,21 @@ export function __clearRegistryCacheForTests(): void {
|
||||
_detailCache.clear();
|
||||
}
|
||||
|
||||
export class RegistryError extends Error {}
|
||||
/**
|
||||
* A registry/install failure. `code` is machine-readable and MUST survive out to
|
||||
* the client: the admin UI decides whether to offer a re-trust override from the
|
||||
* code, never by string-matching the message. A client that matches on prose will
|
||||
* eventually offer the override on the wrong condition — which is the single worst
|
||||
* outcome this surface could produce.
|
||||
*/
|
||||
export class RegistryError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly code?: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PluginRegistryService {
|
||||
@@ -125,8 +161,16 @@ export class PluginRegistryService {
|
||||
}
|
||||
}
|
||||
|
||||
/** The browse list the admin UI renders (metadata only, no code). */
|
||||
async browse(force = false): Promise<Array<Omit<RegistryEntry, 'versions'> & { latest: string | null; minTrekVersion: string | null; requiredAddons: string[]; pluginDependencies: PluginDependency[]; screenshotUrl: string | null }>> {
|
||||
/**
|
||||
* The browse list the admin UI renders (metadata only, no code).
|
||||
*
|
||||
* `signed` describes the LATEST version (browse already treats versions[0] as
|
||||
* latest), so on a version-constrained install the badge can describe a version
|
||||
* other than the artifact actually fetched. Accepted for a Discover card.
|
||||
* `authorPublicKey` is exposed in full — it is a public key, and the re-trust
|
||||
* round-trip compares it exactly (a truncated fingerprint would be a weak check).
|
||||
*/
|
||||
async browse(force = false): Promise<Array<Omit<RegistryEntry, 'versions'> & { latest: string | null; minTrekVersion: string | null; requiredAddons: string[]; pluginDependencies: PluginDependency[]; screenshotUrl: string | null; signed: boolean; authorPublicKey: string | null } & HostCompat>> {
|
||||
const reg = await this.fetchRegistry(force);
|
||||
return reg.plugins.map((p) => {
|
||||
const latest = p.versions[0] ?? null;
|
||||
@@ -137,10 +181,33 @@ export class PluginRegistryService {
|
||||
latest: latest?.version ?? null, minTrekVersion: latest?.minTrekVersion ?? null,
|
||||
requiredAddons: latest?.requiredAddons ?? [], pluginDependencies: latest?.pluginDependencies ?? [],
|
||||
screenshotUrl: latest ? rawFileUrl(p.repo, latest.commitSha, 'docs/screenshot.png') : null,
|
||||
signed: !!p.authorPublicKey && !!latest?.signature,
|
||||
authorPublicKey: p.authorPublicKey ?? null,
|
||||
...this.hostCompat(p),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* How this entry stands against the running TREK, computed SERVER-side. The client has
|
||||
* no semver dependency and must never grow a second implementation of range logic — a
|
||||
* UI that disagreed with the install gate would show an enabled button that 400s.
|
||||
*
|
||||
* `latestCompatible` is the useful half: when the newest version has outrun this TREK,
|
||||
* an older one often still fits, so the UI can offer it instead of a dead grey button.
|
||||
*/
|
||||
private hostCompat(entry: RegistryEntry): HostCompat {
|
||||
const latest = entry.versions[0] ?? null;
|
||||
const compatible = latest ? hostCompatible(latest, normalizedHost()) : false;
|
||||
const fallback = compatible ? null : this.latestCompatible(entry);
|
||||
return {
|
||||
trek: latest?.trek ?? null,
|
||||
hostVersion: hostVersion(),
|
||||
compatible,
|
||||
latestCompatible: compatible ? (latest?.version ?? null) : (fallback?.version ?? null),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the browse detail view shows for one plugin: the registry entry
|
||||
* plus a preview of its live manifest (permissions, egress, settings) fetched
|
||||
@@ -176,6 +243,9 @@ export class PluginRegistryService {
|
||||
size: latest?.size ?? null, publishedAt: latest?.publishedAt ?? null,
|
||||
requiredAddons: latest?.requiredAddons ?? [], pluginDependencies: latest?.pluginDependencies ?? [],
|
||||
screenshotUrl: latest ? rawFileUrl(entry.repo, latest.commitSha, 'docs/screenshot.png') : null,
|
||||
signed: !!entry.authorPublicKey && !!latest?.signature,
|
||||
authorPublicKey: entry.authorPublicKey ?? null,
|
||||
...this.hostCompat(entry),
|
||||
manifest,
|
||||
};
|
||||
// Don't negative-cache a transiently failed manifest fetch — the next
|
||||
@@ -194,32 +264,81 @@ export class PluginRegistryService {
|
||||
const reg = await this.fetchRegistry();
|
||||
const entry = reg.plugins.find((p) => p.id === id);
|
||||
if (!entry) throw new RegistryError(`plugin ${id} not in registry`);
|
||||
const host = hostVersion();
|
||||
const v = this.latestCompatible(entry, constraint);
|
||||
if (!v) {
|
||||
throw new RegistryError(
|
||||
constraint
|
||||
? `no version of ${id} satisfies "${constraint}" and TREK ${hostVersion()}`
|
||||
: `no version of ${id} is compatible with TREK ${hostVersion()}`,
|
||||
'TREK_VERSION_INCOMPATIBLE',
|
||||
);
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
/** The newest version of `entry` that satisfies `constraint` AND admits the running TREK. */
|
||||
private latestCompatible(entry: RegistryEntry, constraint?: string): RegistryVersion | null {
|
||||
const host = normalizedHost();
|
||||
const candidates = entry.versions.filter((v) => {
|
||||
if (constraint && !semver.satisfies(v.version, constraint, { includePrerelease: true })) return false;
|
||||
return hostCompatible(v, host);
|
||||
});
|
||||
if (!candidates.length) {
|
||||
throw new RegistryError(
|
||||
constraint
|
||||
? `no version of ${id} satisfies "${constraint}" and this TREK version`
|
||||
: `no compatible version of ${id} for this TREK version`,
|
||||
);
|
||||
}
|
||||
if (!candidates.length) return null;
|
||||
return [...candidates].sort((a, b) => semver.rcompare(a.version, b.version))[0];
|
||||
}
|
||||
|
||||
/** Install a version from the registry. Returns the installed plugin id + version. */
|
||||
async install(id: string, opts?: { version?: string; constraint?: string }): Promise<{ id: string; version: string }> {
|
||||
/**
|
||||
* Pick the version `install()` will fetch, and refuse an incompatible one — on EVERY
|
||||
* path, which is the whole point. An explicit `version` and the bare "install latest"
|
||||
* both used to bypass the compat check entirely (only the `constraint` path went
|
||||
* through resolveVersion), so the admin UI's own Install button — which sends neither —
|
||||
* happily installed a plugin that declared it doesn't support this TREK.
|
||||
*/
|
||||
private selectVersion(entry: RegistryEntry, opts?: { version?: string; constraint?: string }): RegistryVersion {
|
||||
if (opts?.version) {
|
||||
const ver = entry.versions.find((v) => v.version === opts.version);
|
||||
if (!ver) throw new RegistryError(`version ${opts.version} not found for ${entry.id}`);
|
||||
if (!hostCompatible(ver, normalizedHost())) {
|
||||
throw new RegistryError(
|
||||
`${entry.id} ${ver.version} requires TREK ${trekRequirement(ver)} — this is TREK ${hostVersion()}`,
|
||||
'TREK_VERSION_INCOMPATIBLE',
|
||||
);
|
||||
}
|
||||
return ver;
|
||||
}
|
||||
const ver = this.latestCompatible(entry, opts?.constraint);
|
||||
if (ver) return ver;
|
||||
// Nothing compatible. Say WHY against the newest version the admin was actually
|
||||
// shown, rather than a bare "not found" — the fix is a TREK upgrade, not a retry.
|
||||
const latest = entry.versions[0];
|
||||
if (latest && !opts?.constraint) {
|
||||
throw new RegistryError(
|
||||
`${entry.id} ${latest.version} requires TREK ${trekRequirement(latest)} — this is TREK ${hostVersion()}`,
|
||||
'TREK_VERSION_INCOMPATIBLE',
|
||||
);
|
||||
}
|
||||
throw new RegistryError(
|
||||
opts?.constraint
|
||||
? `no version of ${entry.id} satisfies "${opts.constraint}" and TREK ${hostVersion()}`
|
||||
: `no version of ${entry.id} is compatible with TREK ${hostVersion()}`,
|
||||
'TREK_VERSION_INCOMPATIBLE',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a version from the registry. Returns the installed plugin id + version.
|
||||
*
|
||||
* `retrustKey` is the admin's explicit "I have confirmed this new signing key with
|
||||
* the author out-of-band" override, and it only lifts the TOFU key-change stop —
|
||||
* the artifact must STILL verify under that key (see verifySignatureAndTofu), so a
|
||||
* blessed key that doesn't actually sign the code is refused like any other bad
|
||||
* signature. It is never a way to install something unverified.
|
||||
*/
|
||||
async install(id: string, opts?: { version?: string; constraint?: string; retrustKey?: string }): Promise<{ id: string; version: string }> {
|
||||
const reg = await this.fetchRegistry();
|
||||
const entry = reg.plugins.find((p) => p.id === id);
|
||||
if (!entry) throw new RegistryError(`plugin ${id} not in registry`);
|
||||
const ver = opts?.version
|
||||
? entry.versions.find((v) => v.version === opts.version)
|
||||
: opts?.constraint
|
||||
? await this.resolveVersion(id, opts.constraint)
|
||||
: entry.versions[0];
|
||||
if (!ver) throw new RegistryError(`version ${opts?.version ?? opts?.constraint ?? 'latest'} not found for ${id}`);
|
||||
const ver = this.selectVersion(entry, opts); // throws TREK_VERSION_INCOMPATIBLE before we fetch a byte
|
||||
|
||||
// 1. SSRF-safe download + 2. sha256 verify
|
||||
const { bytes, sha256 } = await safeDownload(ver.downloadUrl, (ver.size ?? 50 * 1024 * 1024) + 4096);
|
||||
@@ -228,7 +347,14 @@ export class PluginRegistryService {
|
||||
// 2b. author signature (opt-in) + TOFU key pin. Unsigned plugins skip this
|
||||
// and install on sha256 alone (unchanged behaviour); a signed plugin must
|
||||
// verify, and its author key must match the one pinned on first install.
|
||||
this.verifySignatureAndTofu(id, bytes, entry, ver);
|
||||
// A refusal is REMEMBERED (the plugin keeps running on its old code, so the
|
||||
// reason must outlive the toast) and re-thrown untouched.
|
||||
try {
|
||||
this.verifySignatureAndTofu(id, bytes, entry, ver, opts?.retrustKey);
|
||||
} catch (e) {
|
||||
if (e instanceof RegistryError && isSignatureCode(e.code)) setUpdateBlock(id, e.code, e.message, ver.version);
|
||||
throw e;
|
||||
}
|
||||
|
||||
// 3. zip/tar-slip-safe extract to staging
|
||||
const staging = path.join(pluginsDataRoot(), '.staging', `${id}-${ver.version}-${Date.now()}`);
|
||||
@@ -238,8 +364,14 @@ export class PluginRegistryService {
|
||||
if (!pluginRoot) throw new RegistryError('archive contains no trek-plugin.json');
|
||||
|
||||
// 4. re-validate the bundled manifest + 5. native re-scan
|
||||
const manifest = parseManifest(parseJsonText(fs.readFileSync(path.join(pluginRoot, 'trek-plugin.json'), 'utf8')));
|
||||
const manifest = parseManifest(parseJsonText(fs.readFileSync(path.join(pluginRoot, 'trek-plugin.json'), 'utf8')), { requireTrek: true });
|
||||
if (manifest.id !== id) throw new RegistryError(`manifest id "${manifest.id}" != "${id}"`);
|
||||
// The artifact's OWN range is the authoritative compat check. The index metadata
|
||||
// gated above is only what the registry says about this version, and it is weaker:
|
||||
// published entries usually carry a lower bound and no upper one at all, so a
|
||||
// plugin that declares "<4.0.0" passes the pre-download filter on TREK 4 and is
|
||||
// caught only here.
|
||||
assertHostCompatible(manifest.trekRange, id);
|
||||
if (scanForNativeBinaries(pluginRoot).length) throw new RegistryError('artifact contains native binaries');
|
||||
|
||||
// 6. atomic move into place
|
||||
@@ -253,10 +385,16 @@ export class PluginRegistryService {
|
||||
db.prepare('UPDATE plugins SET source_repo = ?, source_commit = ?, sha256 = ?, reviewed_at = ? WHERE id = ?').run(
|
||||
entry.repo, ver.commitSha, ver.sha256, entry.reviewedAt ?? null, id,
|
||||
);
|
||||
// Pin the author key on first successful install of a signed plugin (TOFU).
|
||||
// Pin the author key on first successful install of a signed plugin (TOFU) —
|
||||
// and, after a re-trust, re-pin to the new key the admin blessed. Only ever set
|
||||
// to a key the artifact just verified under; NEVER cleared to NULL, because a
|
||||
// NULL pin re-opens the "was never signed" path that accepts an unsigned update.
|
||||
if (entry.authorPublicKey) {
|
||||
db.prepare('UPDATE plugins SET author_pubkey = ? WHERE id = ?').run(entry.authorPublicKey, id);
|
||||
}
|
||||
// The plugin is now on new code that passed every check — whatever refusal was
|
||||
// recorded before no longer describes reality.
|
||||
clearUpdateBlock(id);
|
||||
return { id, version: ver.version };
|
||||
} finally {
|
||||
fs.rmSync(staging, { recursive: true, force: true });
|
||||
@@ -281,7 +419,10 @@ export class PluginRegistryService {
|
||||
if (stack.includes(pid)) throw new RegistryError(`plugin dependency cycle: ${[...stack, pid].join(' -> ')}`);
|
||||
const ver = await this.resolveVersion(pid, range); // throws if not in registry / unsatisfiable
|
||||
if (!installedNow.has(pid)) {
|
||||
await this.install(pid, range ? { constraint: range } : undefined);
|
||||
// Install the version we just RESOLVED. Passing the range back (or nothing, for
|
||||
// the root) made install() re-pick on its own and land on entry.versions[0] —
|
||||
// so the compatible version resolveVersion had chosen was computed and discarded.
|
||||
await this.install(pid, { version: ver.version });
|
||||
installed.push(pid);
|
||||
installedNow.add(pid);
|
||||
}
|
||||
@@ -308,7 +449,8 @@ export class PluginRegistryService {
|
||||
extractArchive(bytes, stagingDir);
|
||||
const root = locateManifestDir(stagingDir);
|
||||
if (!root) throw new RegistryError('archive contains no trek-plugin.json');
|
||||
const manifest = parseManifest(parseJsonText(fs.readFileSync(path.join(root, 'trek-plugin.json'), 'utf8')));
|
||||
const manifest = parseManifest(parseJsonText(fs.readFileSync(path.join(root, 'trek-plugin.json'), 'utf8')), { requireTrek: true });
|
||||
assertHostCompatible(manifest.trekRange, manifest.id);
|
||||
if (scanForNativeBinaries(root).length) throw new RegistryError('artifact contains native binaries');
|
||||
return { id: manifest.id, version: manifest.version, root, stagingDir };
|
||||
} catch (e) {
|
||||
@@ -334,9 +476,17 @@ export class PluginRegistryService {
|
||||
// an existing row's status, so replacing a plugin that was active must not
|
||||
// leave the new code marked active — the admin re-activates (and re-consents
|
||||
// to permissions) explicitly.
|
||||
db.prepare("UPDATE plugins SET source_repo = ?, source_commit = ?, sha256 = ?, reviewed_at = ?, author_pubkey = NULL, status = 'inactive', enabled = 0 WHERE id = ?").run(
|
||||
'local:upload', null, null, null, staged.id,
|
||||
);
|
||||
//
|
||||
// The update-block goes too. It described a REGISTRY update being refused, and this
|
||||
// plugin has just left the registry trust model entirely — the code is now whatever
|
||||
// the admin uploaded. Leaving the block would have the row insist an update was
|
||||
// blocked over a signing key that no longer applies to the code that is running.
|
||||
db.prepare(
|
||||
`UPDATE plugins SET source_repo = ?, source_commit = ?, sha256 = ?, reviewed_at = ?, author_pubkey = NULL,
|
||||
update_block_code = NULL, update_block_detail = NULL, update_block_version = NULL,
|
||||
status = 'inactive', enabled = 0
|
||||
WHERE id = ?`,
|
||||
).run('local:upload', null, null, null, staged.id);
|
||||
} finally {
|
||||
fs.rmSync(staged.stagingDir, { recursive: true, force: true });
|
||||
}
|
||||
@@ -349,26 +499,88 @@ export class PluginRegistryService {
|
||||
* - Neither key nor signature → unsigned plugin, skip (opt-in).
|
||||
* - Key present but no signature (or vice versa) → hard stop; a half-signed
|
||||
* entry is a misconfiguration we refuse rather than silently downgrade.
|
||||
* - Signature invalid → hard stop.
|
||||
* - Signature invalid (or malformed) → hard stop.
|
||||
* - Registry key differs from the key pinned on a prior install → hard stop
|
||||
* (author change / rotation / attack; needs an explicit admin re-trust).
|
||||
*
|
||||
* Each stop carries a machine-readable code, because only ONE of them
|
||||
* (SIGNATURE_KEY_CHANGED) may ever be overridden and the UI must be able to tell
|
||||
* them apart without reading prose.
|
||||
*
|
||||
* `retrustKey` lifts the key-change stop for exactly the key the admin confirmed.
|
||||
* Note what it does NOT do: the signature check below still runs, against the NEW
|
||||
* key, over the artifact bytes. A key an admin blesses must still sign the code it
|
||||
* ships — a re-trust moves the pin from one VERIFIED key to another verified key.
|
||||
*/
|
||||
private verifySignatureAndTofu(id: string, bytes: Buffer, entry: RegistryEntry, ver: RegistryVersion): void {
|
||||
private verifySignatureAndTofu(id: string, bytes: Buffer, entry: RegistryEntry, ver: RegistryVersion, retrustKey?: string): void {
|
||||
const pinned = (db.prepare('SELECT author_pubkey FROM plugins WHERE id = ?').get(id) as { author_pubkey?: string } | undefined)?.author_pubkey ?? null;
|
||||
|
||||
if (!entry.authorPublicKey && !ver.signature) {
|
||||
if (pinned) throw new RegistryError('this plugin was signed before but the update is unsigned — refusing');
|
||||
if (pinned) {
|
||||
throw new RegistryError('this plugin was signed before but the update is unsigned — refusing', 'SIGNATURE_MISSING');
|
||||
}
|
||||
return; // unsigned throughout: sha256 is the only pin
|
||||
}
|
||||
if (!entry.authorPublicKey || !ver.signature) {
|
||||
throw new RegistryError('incomplete signature: an author key and a version signature must both be present');
|
||||
throw new RegistryError('incomplete signature: an author key and a version signature must both be present', 'SIGNATURE_INCOMPLETE');
|
||||
}
|
||||
if (pinned && pinned !== entry.authorPublicKey) {
|
||||
throw new RegistryError("the plugin's author signing key changed since it was installed — re-trust it explicitly to continue");
|
||||
if (pinned && pinned !== entry.authorPublicKey && retrustKey !== entry.authorPublicKey) {
|
||||
throw new RegistryError(
|
||||
"the plugin's author signing key changed since it was installed — re-trust it explicitly to continue",
|
||||
RETRUSTABLE_CODE,
|
||||
);
|
||||
}
|
||||
if (!verifyAuthorSignature(bytes, ver.signature, entry.authorPublicKey)) {
|
||||
throw new RegistryError('author signature verification failed');
|
||||
// verifyAuthorSignature returns false on a well-formed non-matching signature but
|
||||
// THROWS SignatureError on a malformed key/signature. Both mean the same thing to
|
||||
// an admin — the bytes aren't what the author signed — and both are non-overridable.
|
||||
let ok: boolean;
|
||||
try {
|
||||
ok = verifyAuthorSignature(bytes, ver.signature, entry.authorPublicKey);
|
||||
} catch (e) {
|
||||
if (e instanceof SignatureError) {
|
||||
throw new RegistryError(`author signature verification failed: ${e.message}`, 'SIGNATURE_INVALID');
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
if (!ok) throw new RegistryError('author signature verification failed', 'SIGNATURE_INVALID');
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-derive, SERVER-SIDE, that `id` is genuinely in the one overridable condition
|
||||
* (its pinned key no longer matches the registry's) and that `publicKey` is the key
|
||||
* the registry offers RIGHT NOW.
|
||||
*
|
||||
* Both halves matter. The first is the real enforcement of "re-trust is offered only
|
||||
* for a changed key" — the UI hiding the button is a convenience, not the control, so
|
||||
* an admin cannot re-trust their way past an invalid signature by calling the endpoint
|
||||
* directly. The second closes the TOCTOU window: the client sends back the key it
|
||||
* SHOWED the admin, and if the entry has been re-keyed again since the dialog
|
||||
* rendered, the admin would be blessing a key they never saw.
|
||||
*/
|
||||
async assertRetrustable(id: string, publicKey: string): Promise<RegistryEntry> {
|
||||
const row = db.prepare('SELECT source_repo, author_pubkey FROM plugins WHERE id = ?').get(id) as
|
||||
| { source_repo?: string | null; author_pubkey?: string | null }
|
||||
| undefined;
|
||||
if (!row) throw new RegistryError(`plugin ${id} not found`, 'NOT_FOUND');
|
||||
if (!row.source_repo || row.source_repo === 'local:upload' || row.source_repo === 'local:link') {
|
||||
throw new RegistryError('only a registry-installed plugin can be re-trusted', 'RETRUST_NOT_APPLICABLE');
|
||||
}
|
||||
|
||||
const reg = await this.fetchRegistry();
|
||||
const entry = reg.plugins.find((p) => p.id === id);
|
||||
if (!entry) throw new RegistryError(`plugin ${id} not in registry`, 'NOT_FOUND');
|
||||
|
||||
const pinned = row.author_pubkey ?? null;
|
||||
if (!pinned || !entry.authorPublicKey || pinned === entry.authorPublicKey) {
|
||||
throw new RegistryError("this plugin's signing key has not changed — there is nothing to re-trust", 'RETRUST_NOT_APPLICABLE');
|
||||
}
|
||||
if (entry.authorPublicKey !== publicKey) {
|
||||
throw new RegistryError(
|
||||
'the signing key changed again since you were shown it — review the new key before re-trusting',
|
||||
'RETRUST_KEY_MISMATCH',
|
||||
);
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -376,18 +588,49 @@ function rawFileUrl(repo: string, commitSha: string, file: string): string {
|
||||
return `https://raw.githubusercontent.com/${repo}/${commitSha}/${file}`;
|
||||
}
|
||||
|
||||
/** The running TREK version (same source as the rest of the app). */
|
||||
function hostVersion(): string {
|
||||
return process.env.APP_VERSION || (require('../../../../package.json') as { version: string }).version;
|
||||
/**
|
||||
* Whether a registry version admits the running TREK.
|
||||
*
|
||||
* The version's `trek` range is authoritative when the entry carries one. Entries
|
||||
* published before that field existed only carry min/max bounds, so those remain the
|
||||
* fallback — but they are a strictly weaker check (maxTrekVersion is inclusive and is
|
||||
* usually absent entirely, because the SDK never emitted it), which is why install
|
||||
* re-gates on the artifact's own manifest after extraction. This is only the cheap
|
||||
* pre-download filter.
|
||||
*/
|
||||
function hostCompatible(v: RegistryVersion, host: string | null): boolean {
|
||||
if (host === null) return true; // unversioned build — don't block on compat
|
||||
if (v.trek) return hostSatisfies(v.trek, host);
|
||||
if (v.minTrekVersion && semver.valid(v.minTrekVersion) && semver.lt(host, v.minTrekVersion)) return false;
|
||||
if (v.maxTrekVersion && semver.valid(v.maxTrekVersion) && semver.gt(host, v.maxTrekVersion)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Whether a registry version's host-version bounds admit the running TREK. */
|
||||
function hostCompatible(v: RegistryVersion, host: string): boolean {
|
||||
const h = semver.coerce(host)?.version ?? host;
|
||||
if (!semver.valid(h)) return true; // unparseable host — don't block on compat
|
||||
if (v.minTrekVersion && semver.valid(v.minTrekVersion) && semver.lt(h, v.minTrekVersion)) return false;
|
||||
if (v.maxTrekVersion && semver.valid(v.maxTrekVersion) && semver.gt(h, v.maxTrekVersion)) return false;
|
||||
return true;
|
||||
/**
|
||||
* Refuse an artifact whose declared TREK range doesn't admit the running host. Shared by
|
||||
* every install front door (registry, sideload, dev-link) so they all fail the same way,
|
||||
* with a code the admin UI can act on rather than prose it would have to string-match.
|
||||
*/
|
||||
export function assertHostCompatible(range: string | null, id: string): void {
|
||||
if (hostSatisfies(range)) return;
|
||||
throw new RegistryError(
|
||||
`${id} requires TREK ${range} — this is TREK ${hostVersion()}`,
|
||||
'TREK_VERSION_INCOMPATIBLE',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* How a version's TREK requirement reads in an error/UI string. Each bound is optional —
|
||||
* an entry may declare only a ceiling, or (once `trek` is absent too) nothing at all — so
|
||||
* this composes whatever bounds exist rather than interpolating a missing one as "null".
|
||||
*/
|
||||
function trekRequirement(v: RegistryVersion): string {
|
||||
if (v.trek) return v.trek;
|
||||
const bounds = [
|
||||
v.minTrekVersion ? `>=${v.minTrekVersion}` : null,
|
||||
v.maxTrekVersion ? `<=${v.maxTrekVersion}` : null,
|
||||
].filter(Boolean);
|
||||
return bounds.length ? bounds.join(' ') : 'any version';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -788,9 +788,19 @@ export function createPluginContext(
|
||||
},
|
||||
events: {
|
||||
// Fire-and-forget by contract, but the host CAN reject (undeclared event name,
|
||||
// rate-limit) — swallow it, otherwise the detached rejection crashes the child
|
||||
// and terminally disables the plugin over one bad emit.
|
||||
emit: (name, payload) => { t.rpc('events.emit', { event: name, payload }).catch(() => {}); },
|
||||
// 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 the manifest.
|
||||
// Surface it on the plugin's own log stream instead.
|
||||
emit: (name, payload) => {
|
||||
t.rpc('events.emit', { event: name, payload }).catch((e: unknown) => {
|
||||
t.emit('log', {
|
||||
level: 'warn',
|
||||
msg: `events.emit("${name}") was rejected by the host: ${e instanceof Error ? e.message : String(e)}`,
|
||||
meta: { event: name },
|
||||
});
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { db } from '../../db/database';
|
||||
|
||||
/**
|
||||
* Signature/trust status shared between the registry installer, the read-side
|
||||
* plugin service and the admin controller (#plugins).
|
||||
*
|
||||
* TREK verifies an author's Ed25519 signature and TOFU-pins the key on first
|
||||
* install, but until now none of that was ever surfaced: 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 in a toast.
|
||||
* These helpers give the four failure conditions machine-readable codes and
|
||||
* persist the refusal so the admin list can keep showing it.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Machine-readable reason a signature check refused an install.
|
||||
*
|
||||
* Only SIGNATURE_KEY_CHANGED is overridable (an author can legitimately rotate a
|
||||
* key). The other three mean the bytes are not what the author signed — there is
|
||||
* no story where waving that through is the right answer, so no override exists
|
||||
* for them, not even a disabled one.
|
||||
*/
|
||||
export type SignatureCode =
|
||||
| 'SIGNATURE_MISSING'
|
||||
| 'SIGNATURE_INCOMPLETE'
|
||||
| 'SIGNATURE_KEY_CHANGED'
|
||||
| 'SIGNATURE_INVALID';
|
||||
|
||||
/** The one code an admin may override, via POST /api/admin/plugins/:id/retrust. */
|
||||
export const RETRUSTABLE_CODE: SignatureCode = 'SIGNATURE_KEY_CHANGED';
|
||||
|
||||
export function isSignatureCode(code: string | undefined): code is SignatureCode {
|
||||
return (
|
||||
code === 'SIGNATURE_MISSING' ||
|
||||
code === 'SIGNATURE_INCOMPLETE' ||
|
||||
code === 'SIGNATURE_KEY_CHANGED' ||
|
||||
code === 'SIGNATURE_INVALID'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A short, human-comparable form of a minisign/base64 public key — the thing an
|
||||
* admin reads out to the author over the phone to confirm a rotation ("pinned
|
||||
* A1b2c3d4…w7x8y9z0, new …"). Display only: the re-trust round-trip carries the
|
||||
* FULL key, because a truncated fingerprint is a weak equality check. There is
|
||||
* nothing secret about either — it is a public key.
|
||||
*/
|
||||
export function keyFingerprint(pubkey: string | null | undefined): string | null {
|
||||
if (!pubkey) return null;
|
||||
const payload = pubkey
|
||||
.trim()
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l && !l.startsWith('untrusted comment'))
|
||||
.pop();
|
||||
if (!payload) return null;
|
||||
return payload.length <= 20 ? payload : `${payload.slice(0, 8)}…${payload.slice(-8)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remember WHY an update was refused, so the Installed row can keep saying so
|
||||
* instead of making the admin re-attempt the update to rediscover the reason.
|
||||
* `version` is the registry version that was refused — the block is treated as
|
||||
* stale (and the admin can just re-attempt) once the registry offers a newer one.
|
||||
*
|
||||
* Deliberately does NOT touch `status`: the plugin is still running fine on its
|
||||
* old code. A blocked update is not a broken runtime, and conflating them would
|
||||
* make the isolation-health dot lie.
|
||||
*/
|
||||
export function setUpdateBlock(id: string, code: SignatureCode, detail: string, version: string | null): void {
|
||||
try {
|
||||
db.prepare('UPDATE plugins SET update_block_code = ?, update_block_detail = ?, update_block_version = ? WHERE id = ?').run(
|
||||
code,
|
||||
detail,
|
||||
version,
|
||||
id,
|
||||
);
|
||||
} catch {
|
||||
// Columns absent (a slimmed test app) — the block is a nicety, never a gate.
|
||||
}
|
||||
}
|
||||
|
||||
/** Clear a recorded block. Called on a successful install/update — NOT on activate:
|
||||
* activating the plugin at its OLD version resolves nothing, and letting an off/on
|
||||
* toggle erase the warning is exactly the silent-stops-updating failure this exists
|
||||
* to prevent. (Uninstall drops the row entirely, so it needs no explicit clear.) */
|
||||
export function clearUpdateBlock(id: string): void {
|
||||
try {
|
||||
db.prepare('UPDATE plugins SET update_block_code = NULL, update_block_detail = NULL, update_block_version = NULL WHERE id = ?').run(id);
|
||||
} catch {
|
||||
// See setUpdateBlock.
|
||||
}
|
||||
}
|
||||
@@ -107,6 +107,7 @@ const ACTION_LABELS: Record<string, string> = {
|
||||
'trip.delete': 'deleted trip',
|
||||
'admin.user_role_change': 'changed user role',
|
||||
'admin.user_delete': 'deleted user',
|
||||
'admin.plugin_retrust': "re-trusted a plugin's author signing key",
|
||||
'admin.invite_create': 'created invite',
|
||||
'immich.private_ip_configured': 'configured Immich with private IP',
|
||||
};
|
||||
|
||||
@@ -54,6 +54,7 @@ import { createUser } from '../helpers/factories';
|
||||
import { generateToken } from '../helpers/auth';
|
||||
import { createMcpToken } from '../helpers/factories';
|
||||
import { closeMcpSessions } from '../../src/mcp/index';
|
||||
import { sessions } from '../../src/mcp/sessionManager';
|
||||
|
||||
let nestApp: INestApplication;
|
||||
let app: Application;
|
||||
@@ -208,24 +209,25 @@ describe('MCP session management', () => {
|
||||
return sessionId as string;
|
||||
}
|
||||
|
||||
it('MCP-003 — session limit of 5 per user', async () => {
|
||||
it('MCP-003 — at the session cap, the coldest session is evicted rather than the request refused', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
testDb.prepare("UPDATE addons SET enabled = 1 WHERE id = 'mcp'").run();
|
||||
|
||||
// Create 5 sessions
|
||||
for (let i = 0; i < 20; i++) {
|
||||
await createSession(user.id);
|
||||
}
|
||||
const sessionsForUser = () => [...sessions.values()].filter((s) => s.userId === user.id).length;
|
||||
|
||||
// 6th should fail
|
||||
const token = generateToken(user.id);
|
||||
const res = await request(app)
|
||||
.post('/mcp')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.set('Accept', 'application/json, text/event-stream')
|
||||
.send({ jsonrpc: '2.0', method: 'initialize', id: 1, params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'test', version: '1' } } });
|
||||
expect(res.status).toBe(429);
|
||||
expect(res.body.error).toMatch(/session limit/i);
|
||||
// Fill the default cap of 20.
|
||||
const firstSessionId = await createSession(user.id);
|
||||
for (let i = 1; i < 20; i++) await createSession(user.id);
|
||||
expect(sessionsForUser()).toBe(20);
|
||||
|
||||
// The 21st initialize must still succeed. A hard 429 here is what wedged real users: a
|
||||
// client that can't persist its Mcp-Session-Id re-initializes on every tool call, and
|
||||
// would be locked out of the server permanently once it hit the cap.
|
||||
const newSessionId = await createSession(user.id);
|
||||
|
||||
expect(sessionsForUser()).toBe(20); // capped, not growing
|
||||
expect(sessions.has(newSessionId)).toBe(true);
|
||||
expect(sessions.has(firstSessionId)).toBe(false); // the least-recently-active one made room
|
||||
});
|
||||
|
||||
it('MCP — session resumption with valid mcp-session-id', async () => {
|
||||
@@ -259,6 +261,44 @@ describe('MCP session management', () => {
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('MCP — a session-less non-initialize POST is rejected without registering a session', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
testDb.prepare("UPDATE addons SET enabled = 1 WHERE id = 'mcp'").run();
|
||||
const token = generateToken(user.id);
|
||||
|
||||
const before = sessions.size;
|
||||
const res = await request(app)
|
||||
.post('/mcp')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.set('Accept', 'application/json, text/event-stream')
|
||||
.send({ jsonrpc: '2.0', method: 'tools/list', id: 1, params: {} });
|
||||
|
||||
// The SDK rejects it ("Server not initialized"); the McpServer built to serve it must not
|
||||
// linger — it is in no session, so nothing would ever sweep or close it.
|
||||
expect(res.status).toBe(400);
|
||||
expect(sessions.size).toBe(before);
|
||||
});
|
||||
|
||||
it('MCP — initialize response exposes Mcp-Session-Id to browser-context clients', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
testDb.prepare("UPDATE addons SET enabled = 1 WHERE id = 'mcp'").run();
|
||||
const token = generateToken(user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/mcp')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.set('Origin', 'https://claude.ai')
|
||||
.set('Accept', 'application/json, text/event-stream')
|
||||
.send({ jsonrpc: '2.0', method: 'initialize', id: 1, params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'test', version: '1' } } });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['mcp-session-id']).toBeTruthy();
|
||||
// Without this header the Fetch spec hides Mcp-Session-Id from the client, so it can never
|
||||
// echo it back and every tool call mints a fresh session until the cap kills the connection.
|
||||
expect(String(res.headers['access-control-expose-headers'] ?? '').toLowerCase())
|
||||
.toContain('mcp-session-id');
|
||||
});
|
||||
|
||||
it('MCP — GET without mcp-session-id returns 400', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
testDb.prepare("UPDATE addons SET enabled = 1 WHERE id = 'mcp'").run();
|
||||
|
||||
@@ -15,9 +15,10 @@ const { testDb } = vi.hoisted(() => {
|
||||
const db = new Database(':memory:');
|
||||
db.exec(`CREATE TABLE plugins (
|
||||
id TEXT PRIMARY KEY, name TEXT, description TEXT, type TEXT, icon TEXT, version TEXT, api_version INTEGER,
|
||||
min_trek_version TEXT, permissions TEXT DEFAULT '[]', capabilities TEXT DEFAULT '{}', dependencies TEXT DEFAULT '{}',
|
||||
min_trek_version TEXT, trek_range TEXT, permissions TEXT DEFAULT '[]', capabilities TEXT DEFAULT '{}', dependencies TEXT DEFAULT '{}',
|
||||
operator_egress INTEGER DEFAULT 0, granted_permissions TEXT DEFAULT '', status TEXT, enabled INTEGER DEFAULT 0, config TEXT DEFAULT '{}',
|
||||
source_repo TEXT, source_commit TEXT, sha256 TEXT, author_pubkey TEXT, reviewed_at TEXT, last_error TEXT, updated_at TEXT);
|
||||
source_repo TEXT, source_commit TEXT, sha256 TEXT, author_pubkey TEXT, reviewed_at TEXT, last_error TEXT, updated_at TEXT,
|
||||
update_block_code TEXT, update_block_detail TEXT, update_block_version TEXT);
|
||||
CREATE TABLE plugin_error_log (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, level TEXT, message TEXT, ts TEXT);
|
||||
CREATE TABLE plugin_settings_fields (plugin_id TEXT, field_key TEXT, label TEXT, input_type TEXT, placeholder TEXT, hint TEXT, required INTEGER, secret INTEGER, scope TEXT, options TEXT, oauth_config TEXT, sort_order INTEGER);
|
||||
CREATE TABLE settings (user_id INTEGER, key TEXT, value TEXT);
|
||||
@@ -38,10 +39,12 @@ let runtime: PluginRuntimeService;
|
||||
const ROUTE = (v: string) =>
|
||||
`module.exports = { routes: [{ method: 'GET', path: '/v', auth: false, async handler() { return { status: 200, body: JSON.stringify({ v: ${v} }) }; } }] };`;
|
||||
|
||||
function writeSource(id: string, opts: { index?: string; native?: boolean; noBuild?: boolean } = {}): string {
|
||||
function writeSource(id: string, opts: { index?: string; native?: boolean; noBuild?: boolean; trek?: string } = {}): string {
|
||||
const dir = path.join(srcRoot, id);
|
||||
fs.mkdirSync(path.join(dir, 'server'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'trek-plugin.json'), JSON.stringify({ id, name: id, version: '1.0.0', type: 'integration', permissions: [] }));
|
||||
// dev-link requires a `trek` range like any other install front door; `opts.trek`
|
||||
// overrides it to exercise the gate.
|
||||
fs.writeFileSync(path.join(dir, 'trek-plugin.json'), JSON.stringify({ id, name: id, version: '1.0.0', type: 'integration', permissions: [], trek: opts.trek ?? '>=3.0.0 <4.0.0' }));
|
||||
if (!opts.noBuild) fs.writeFileSync(path.join(dir, 'server', 'index.js'), opts.index ?? 'module.exports = {};');
|
||||
if (opts.native) fs.writeFileSync(path.join(dir, 'server', 'addon.node'), '\0');
|
||||
return dir;
|
||||
@@ -97,6 +100,21 @@ describe('PluginRuntimeService dev-link', () => {
|
||||
await expect(runtime.link(writeSource('nativeplug', { native: true }))).rejects.toThrow(/native/);
|
||||
});
|
||||
|
||||
it('rejects a local dir whose TREK range this server does not satisfy', async () => {
|
||||
// Dev-link is the third install front door, and it hands TREK code to RUN against real
|
||||
// data — the fact that the author is standing right there is not a reason to skip the
|
||||
// check that the code supports the host it is about to be spawned on.
|
||||
process.env.APP_VERSION = '3.3.0';
|
||||
try {
|
||||
await expect(runtime.link(writeSource('oldplug', { trek: '>=2.0.0 <3.0.0' }))).rejects.toMatchObject({
|
||||
code: 'TREK_VERSION_INCOMPATIBLE',
|
||||
});
|
||||
await expect(runtime.link(writeSource('rangeless', { trek: '' }))).rejects.toThrow(/missing "trek"/);
|
||||
} finally {
|
||||
delete process.env.APP_VERSION;
|
||||
}
|
||||
});
|
||||
|
||||
it('refuses to clobber a real (non-linked) installed plugin of the same id', async () => {
|
||||
testDb.prepare("INSERT INTO plugins (id, name, type, version, status, source_repo) VALUES ('installed','X','integration','1.0.0','inactive','local:upload')").run();
|
||||
await expect(runtime.link(writeSource('installed'))).rejects.toThrow(/already installed/);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* HTTP route works through the host→child invoke path, using its own isolated
|
||||
* db. Proves the full activate → route → deactivate loop.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
@@ -13,7 +13,13 @@ const { testDb } = vi.hoisted(() => {
|
||||
const db = new Database(':memory:');
|
||||
db.exec(`CREATE TABLE plugins (
|
||||
id TEXT PRIMARY KEY, status TEXT, enabled INTEGER DEFAULT 0, version TEXT, permissions TEXT DEFAULT '[]', operator_egress INTEGER DEFAULT 0, granted_permissions TEXT DEFAULT '',
|
||||
config TEXT DEFAULT '{}', dependencies TEXT DEFAULT '{}', capabilities TEXT DEFAULT '{}', last_error TEXT, updated_at TEXT);
|
||||
config TEXT DEFAULT '{}', dependencies TEXT DEFAULT '{}', capabilities TEXT DEFAULT '{}', last_error TEXT, updated_at TEXT,
|
||||
-- Activation refuses a plugin that declares no TREK range, so the fixtures default to a
|
||||
-- satisfied one: these tests are about permissions/dependencies, and every row would
|
||||
-- otherwise fail on the version gate before reaching the behaviour under test. The gate
|
||||
-- itself is exercised in "TREK host-version gating" below, with explicit ranges.
|
||||
trek_range TEXT DEFAULT '>=3.0.0',
|
||||
source_repo TEXT, author_pubkey TEXT, update_block_code TEXT, update_block_detail TEXT, update_block_version TEXT);
|
||||
CREATE TABLE plugin_error_log (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, level TEXT, message TEXT, ts TEXT);
|
||||
CREATE TABLE plugin_settings_fields (plugin_id TEXT, field_key TEXT, scope TEXT, secret INTEGER);
|
||||
CREATE TABLE settings (user_id INTEGER, key TEXT, value TEXT);
|
||||
@@ -25,7 +31,15 @@ const { testDb } = vi.hoisted(() => {
|
||||
CREATE TABLE plugin_capability_audit (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, acting_user_id INTEGER, method TEXT, resource TEXT, code TEXT, ts TEXT, prev_hash TEXT, hash TEXT);
|
||||
CREATE TABLE plugin_scheduled_tasks (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT NOT NULL, name TEXT NOT NULL, due_at INTEGER NOT NULL, payload TEXT NOT NULL DEFAULT 'null', every_ms INTEGER, created_at TEXT DEFAULT (datetime('now')), UNIQUE(plugin_id, name));
|
||||
CREATE TABLE plugin_user_erasure_queue (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT NOT NULL, user_id INTEGER NOT NULL, created_at TEXT DEFAULT (datetime('now')), UNIQUE(plugin_id, user_id));
|
||||
CREATE TABLE addons (id TEXT PRIMARY KEY, enabled INTEGER DEFAULT 0);`);
|
||||
CREATE TABLE addons (id TEXT PRIMARY KEY, enabled INTEGER DEFAULT 0);
|
||||
-- The ADMIN audit log. Re-trusting a signing key is precisely the event that has to be
|
||||
-- reconstructible after an incident, so the write is asserted rather than assumed —
|
||||
-- writeAudit swallows its own failures, so without this table the audit silently did
|
||||
-- nothing and every test still passed. No users FK: this slim db has no users table
|
||||
-- (writeAudit's resolveUserEmail already degrades to "uid:N" on its own).
|
||||
CREATE TABLE audit_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
user_id INTEGER, action TEXT NOT NULL, resource TEXT, details TEXT, ip TEXT);`);
|
||||
return { testDb: db };
|
||||
});
|
||||
vi.mock('../../../src/db/database', () => ({ db: testDb, canAccessTrip: () => undefined }));
|
||||
@@ -321,8 +335,13 @@ describe('PluginRuntimeService (M2 end-to-end)', () => {
|
||||
});
|
||||
|
||||
describe('PluginRuntimeService.update (re-consent gate)', () => {
|
||||
const fakeRegistry = (impl: () => void) =>
|
||||
({ install: vi.fn(async () => { impl(); return { id: 'x', version: '2.0.0' }; }) }) as unknown as import('../../../src/nest/plugins/registry/registry.service').PluginRegistryService;
|
||||
// update() now asks the registry which version this TREK can actually run before it
|
||||
// installs anything, so the fake has to answer resolveVersion as well as install.
|
||||
const fakeRegistry = (impl: () => void, latest = '2.0.0') =>
|
||||
({
|
||||
resolveVersion: vi.fn(async () => ({ version: latest })),
|
||||
install: vi.fn(async () => { impl(); return { id: 'x', version: latest }; }),
|
||||
}) as unknown as import('../../../src/nest/plugins/registry/registry.service').PluginRegistryService;
|
||||
|
||||
const seed = (id: string, enabled: number, permissions: string[], granted: string[]) => {
|
||||
fs.mkdirSync(path.join(codeRoot, id, 'server'), { recursive: true });
|
||||
@@ -500,3 +519,202 @@ describe('PluginRuntimeService inter-plugin (exports + events)', () => {
|
||||
expect(() => runtime.emitPluginEvent('lib', 'not-declared', {})).toThrow(/does not declare event/);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Re-trust: re-pinning a ROTATED author signing key and updating, in one call.
|
||||
*
|
||||
* The one-call shape is the safety property. A re-pin that returned and left the client
|
||||
* to then call /update would leave a window where author_pubkey is pinned to a key no
|
||||
* install has ever verified against — and if that second call never arrives (the admin
|
||||
* closes the tab), the plugin just sits there, pinned to an unverified key.
|
||||
*/
|
||||
describe('PluginRuntimeService.retrust', () => {
|
||||
const seed = (id: string, pinned: string) => {
|
||||
fs.mkdirSync(path.join(codeRoot, id, 'server'), { recursive: true });
|
||||
fs.writeFileSync(path.join(codeRoot, id, 'server', 'index.js'), 'module.exports = { async onLoad() {} };');
|
||||
testDb
|
||||
.prepare(
|
||||
`INSERT INTO plugins (id, status, enabled, version, permissions, granted_permissions, config, source_repo, author_pubkey,
|
||||
update_block_code, update_block_detail, update_block_version)
|
||||
VALUES (?,'active',1,'1.0.0','["db:own"]','["db:own"]','{}','acme/x',?,'SIGNATURE_KEY_CHANGED','key changed','2.0.0')`,
|
||||
)
|
||||
.run(id, pinned);
|
||||
};
|
||||
|
||||
/** A registry whose install() re-pins to the new key, as the real one does. */
|
||||
const registryFor = (id: string) =>
|
||||
({
|
||||
assertRetrustable: vi.fn(async (_id: string, key: string) => ({ authorPublicKey: key })),
|
||||
install: vi.fn(async (_id: string, opts?: { retrustKey?: string }) => {
|
||||
testDb
|
||||
.prepare("UPDATE plugins SET author_pubkey = ?, version = '2.0.0', update_block_code = NULL, update_block_version = NULL WHERE id = ?")
|
||||
.run(opts?.retrustKey, id);
|
||||
return { id, version: '2.0.0' };
|
||||
}),
|
||||
}) as unknown as import('../../../src/nest/plugins/registry/registry.service').PluginRegistryService;
|
||||
|
||||
it('re-pins AND installs in one call: the new key is pinned, the version moved, the block gone', async () => {
|
||||
seed('rt-ok', 'OLDKEY');
|
||||
const registry = registryFor('rt-ok');
|
||||
const rt = new PluginRuntimeService(registry);
|
||||
await rt.activate('rt-ok');
|
||||
|
||||
await rt.retrust('rt-ok', '2.0.0', 'NEWKEY', { userId: 1, ip: '10.0.0.1' });
|
||||
|
||||
// The condition is re-derived SERVER-side before anything moves — the UI hiding the
|
||||
// button is a convenience, not the control.
|
||||
expect(registry.assertRetrustable).toHaveBeenCalledWith('rt-ok', 'NEWKEY');
|
||||
const row = testDb.prepare("SELECT author_pubkey, version, update_block_code FROM plugins WHERE id='rt-ok'").get() as {
|
||||
author_pubkey: string | null;
|
||||
version: string;
|
||||
update_block_code: string | null;
|
||||
};
|
||||
expect(row.author_pubkey).toBe('NEWKEY');
|
||||
expect(row.author_pubkey).not.toBeNull(); // never NULL: that would re-open the unsigned path
|
||||
expect(row.version).toBe('2.0.0');
|
||||
expect(row.update_block_code).toBeNull(); // no half-state left for the admin to trip over
|
||||
|
||||
// Audited. Both fingerprints are the point: after an incident, "which key did we move
|
||||
// FROM, and to what?" is the question, and an entry that records only the new key
|
||||
// cannot answer it. This goes to the ADMIN log — a lifecycle act by an admin does not
|
||||
// belong in the plugin capability log, which answers "what have plugins done in my name?"
|
||||
const audit = testDb.prepare("SELECT * FROM audit_log WHERE action = 'admin.plugin_retrust'").get() as {
|
||||
user_id: number;
|
||||
resource: string;
|
||||
details: string;
|
||||
ip: string;
|
||||
};
|
||||
expect(audit).toBeDefined();
|
||||
expect(audit.resource).toBe('rt-ok');
|
||||
expect(audit.user_id).toBe(1);
|
||||
expect(audit.ip).toBe('10.0.0.1');
|
||||
expect(JSON.parse(audit.details)).toEqual({
|
||||
plugin: 'rt-ok',
|
||||
version: '2.0.0',
|
||||
oldKeyFingerprint: 'OLDKEY',
|
||||
newKeyFingerprint: 'NEWKEY',
|
||||
});
|
||||
await rt.deactivate('rt-ok');
|
||||
});
|
||||
|
||||
it('changes nothing when the pre-check refuses (an invalid signature is not re-trustable)', async () => {
|
||||
seed('rt-no', 'OLDKEY');
|
||||
const registry = registryFor('rt-no');
|
||||
vi.mocked(registry.assertRetrustable).mockRejectedValue(new Error('nothing to re-trust'));
|
||||
const rt = new PluginRuntimeService(registry);
|
||||
|
||||
await expect(rt.retrust('rt-no', '2.0.0', 'NEWKEY', { userId: 1 })).rejects.toThrow(/nothing to re-trust/);
|
||||
|
||||
expect(registry.install).not.toHaveBeenCalled();
|
||||
const row = testDb.prepare("SELECT author_pubkey, update_block_code FROM plugins WHERE id='rt-no'").get() as {
|
||||
author_pubkey: string;
|
||||
update_block_code: string;
|
||||
};
|
||||
expect(row.author_pubkey).toBe('OLDKEY');
|
||||
expect(row.update_block_code).toBe('SIGNATURE_KEY_CHANGED'); // the block stands
|
||||
|
||||
// …and nothing is audited. A refused re-trust must not leave a trace that reads like
|
||||
// one that happened — an audit log that logs attempts as if they were acts is worse
|
||||
// than no audit log, because it is believed.
|
||||
const audited = testDb
|
||||
.prepare("SELECT COUNT(*) AS n FROM audit_log WHERE action = 'admin.plugin_retrust' AND resource = 'rt-no'")
|
||||
.get() as { n: number };
|
||||
expect(audited.n).toBe(0);
|
||||
});
|
||||
|
||||
// Activating the plugin at its OLD version resolves nothing. If an off/on toggle wiped
|
||||
// the block, the admin would silently lose the warning while it still applied — which is
|
||||
// exactly the "quietly stops updating" failure the block exists to prevent.
|
||||
it('activate does NOT clear a recorded update block', async () => {
|
||||
seed('rt-act', 'OLDKEY');
|
||||
const rt = new PluginRuntimeService(registryFor('rt-act'));
|
||||
|
||||
await rt.deactivate('rt-act');
|
||||
await rt.activate('rt-act');
|
||||
|
||||
const row = testDb.prepare("SELECT update_block_code, update_block_version FROM plugins WHERE id='rt-act'").get() as {
|
||||
update_block_code: string;
|
||||
update_block_version: string;
|
||||
};
|
||||
expect(row.update_block_code).toBe('SIGNATURE_KEY_CHANGED');
|
||||
expect(row.update_block_version).toBe('2.0.0');
|
||||
await rt.deactivate('rt-act');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The activation gate is where an incompatible plugin is actually STOPPED.
|
||||
*
|
||||
* Install alone can't be the whole answer: a plugin installed legitimately on TREK 3.3
|
||||
* still sits on disk after the operator upgrades to 4.0, and its code — written against a
|
||||
* host it declared it doesn't support — would otherwise be spawned on the next boot.
|
||||
*/
|
||||
describe('TREK host-version gating', () => {
|
||||
const seedRanged = (id: string, trekRange: string | null, enabled = 0) => {
|
||||
fs.mkdirSync(path.join(codeRoot, id, 'server'), { recursive: true });
|
||||
fs.writeFileSync(path.join(codeRoot, id, 'server', 'index.js'), 'module.exports = { async onLoad() {} };');
|
||||
testDb.prepare("INSERT INTO plugins (id, status, enabled, permissions, granted_permissions, config, trek_range) VALUES (?, ?, ?, '[]', '[]', '{}', ?)")
|
||||
.run(id, enabled ? 'active' : 'inactive', enabled, trekRange);
|
||||
};
|
||||
const cleanup = (...ids: string[]) => { for (const id of ids) testDb.prepare('DELETE FROM plugins WHERE id = ?').run(id); };
|
||||
|
||||
afterEach(() => { delete process.env.APP_VERSION; });
|
||||
|
||||
it('refuses to activate a plugin this TREK has outgrown, and says so with a code', async () => {
|
||||
process.env.APP_VERSION = '4.0.0';
|
||||
seedRanged('outgrown', '>=3.2.0 <4.0.0');
|
||||
const err = await new PluginRuntimeService().activate('outgrown').catch((e) => e);
|
||||
expect(err).toBeInstanceOf(PluginDependencyError);
|
||||
expect(err).toMatchObject({ code: 'TREK_VERSION_INCOMPATIBLE' });
|
||||
expect(err.detail).toMatchObject({ trekRange: '>=3.2.0 <4.0.0', hostVersion: '4.0.0' });
|
||||
// It stays INSTALLED and visible — the admin needs to see it to act on it.
|
||||
expect(testDb.prepare("SELECT id FROM plugins WHERE id='outgrown'").get()).toBeTruthy();
|
||||
cleanup('outgrown');
|
||||
});
|
||||
|
||||
it('refuses a plugin that never declared a range at all', async () => {
|
||||
process.env.APP_VERSION = '3.3.0';
|
||||
seedRanged('undeclared', null);
|
||||
await expect(new PluginRuntimeService().activate('undeclared')).rejects.toMatchObject({ code: 'TREK_VERSION_UNKNOWN' });
|
||||
cleanup('undeclared');
|
||||
});
|
||||
|
||||
it('activates normally when the range admits the host', async () => {
|
||||
process.env.APP_VERSION = '3.3.0';
|
||||
seedRanged('fits', '>=3.2.0 <4.0.0');
|
||||
const rt = new PluginRuntimeService();
|
||||
await rt.activate('fits');
|
||||
expect(rt.isActive('fits')).toBe(true);
|
||||
await rt.deactivate('fits');
|
||||
cleanup('fits');
|
||||
});
|
||||
|
||||
it('boots cleanly with an enabled-but-incompatible plugin, reconciling it to disabled', async () => {
|
||||
// The regression this guards: a bespoke error class here would miss the boot loop's
|
||||
// reconciliation (it only catches PluginDependencyError / DependencyCycleError), so the
|
||||
// row would keep enabled=1 + status='active' while no child was ever spawned — the admin
|
||||
// panel showing a green, running plugin that does not exist.
|
||||
process.env.APP_VERSION = '4.0.0';
|
||||
seedRanged('boot-broken', '>=3.2.0 <4.0.0', 1);
|
||||
const rt = new PluginRuntimeService();
|
||||
expect(() => rt.onModuleInit()).not.toThrow();
|
||||
for (let i = 0; i < 40; i++) {
|
||||
const row = testDb.prepare("SELECT enabled FROM plugins WHERE id='boot-broken'").get() as { enabled: number };
|
||||
if (row.enabled === 0) break;
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
}
|
||||
expect(testDb.prepare("SELECT enabled FROM plugins WHERE id='boot-broken'").get()).toMatchObject({ enabled: 0 });
|
||||
expect(rt.isActive('boot-broken')).toBe(false);
|
||||
cleanup('boot-broken');
|
||||
});
|
||||
|
||||
it('checks the version BEFORE permissions — an unrunnable plugin gets no consent dialog', async () => {
|
||||
// Consenting to permissions would not make it start, so offering the dialog is a lie.
|
||||
process.env.APP_VERSION = '4.0.0';
|
||||
fs.mkdirSync(path.join(codeRoot, 'both-wrong', 'server'), { recursive: true });
|
||||
fs.writeFileSync(path.join(codeRoot, 'both-wrong', 'server', 'index.js'), 'module.exports = {};');
|
||||
testDb.prepare("INSERT INTO plugins (id, status, enabled, permissions, granted_permissions, config, trek_range) VALUES ('both-wrong','inactive',0,'[\"db:own\"]','[]','{}','>=3.2.0 <4.0.0')").run();
|
||||
await expect(new PluginRuntimeService().activate('both-wrong')).rejects.toMatchObject({ code: 'TREK_VERSION_INCOMPATIBLE' });
|
||||
cleanup('both-wrong');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,8 +21,9 @@ const { testDb } = vi.hoisted(() => {
|
||||
const db = new Database(':memory:');
|
||||
db.exec(`
|
||||
CREATE TABLE plugins (id TEXT PRIMARY KEY, name TEXT, description TEXT, type TEXT, icon TEXT, version TEXT,
|
||||
api_version INTEGER, min_trek_version TEXT, permissions TEXT, capabilities TEXT DEFAULT '{}', dependencies TEXT DEFAULT '{}', operator_egress INTEGER DEFAULT 0, granted_permissions TEXT, status TEXT, enabled INTEGER DEFAULT 0, config TEXT,
|
||||
source_repo TEXT, source_commit TEXT, sha256 TEXT, reviewed_at TEXT, author_pubkey TEXT, updated_at TEXT);
|
||||
api_version INTEGER, min_trek_version TEXT, trek_range TEXT, permissions TEXT, capabilities TEXT DEFAULT '{}', dependencies TEXT DEFAULT '{}', operator_egress INTEGER DEFAULT 0, granted_permissions TEXT, status TEXT, enabled INTEGER DEFAULT 0, config TEXT,
|
||||
source_repo TEXT, source_commit TEXT, sha256 TEXT, reviewed_at TEXT, author_pubkey TEXT, updated_at TEXT,
|
||||
update_block_code TEXT, update_block_detail TEXT, update_block_version TEXT);
|
||||
CREATE TABLE plugin_settings_fields (plugin_id TEXT, field_key TEXT, label TEXT, input_type TEXT, placeholder TEXT, hint TEXT, required INTEGER, secret INTEGER, scope TEXT, options TEXT, oauth_config TEXT, sort_order INTEGER);
|
||||
CREATE TABLE plugin_error_log (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, level TEXT, message TEXT, ts TEXT);`);
|
||||
return { testDb: db };
|
||||
@@ -41,10 +42,14 @@ function tarHeader(name: string, size: number, typeflag = '0'): Buffer {
|
||||
h.write(sum.toString(8).padStart(6, '0') + '\0 ', 148);
|
||||
return h;
|
||||
}
|
||||
// Every artifact declares a `trek` range by default — install REQUIRES one, so a fixture
|
||||
// without it would be testing the version gate rather than whatever the test is about.
|
||||
// Pass an explicit `trek` (or null, spread last) to exercise the gate itself.
|
||||
function makeArtifact(manifest: object): Buffer {
|
||||
const withTrek = { trek: '>=3.2.0 <4.0.0', ...manifest };
|
||||
const files = [
|
||||
{ name: 'plug-abc/', type: '5' as const, data: '' },
|
||||
{ name: 'plug-abc/trek-plugin.json', type: '0' as const, data: JSON.stringify(manifest) },
|
||||
{ name: 'plug-abc/trek-plugin.json', type: '0' as const, data: JSON.stringify(withTrek) },
|
||||
{ name: 'plug-abc/server/', type: '5' as const, data: '' },
|
||||
{ name: 'plug-abc/server/index.js', type: '0' as const, data: 'module.exports={}' },
|
||||
];
|
||||
@@ -368,6 +373,239 @@ describe('PluginRegistryService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Machine-readable codes on the four signature refusals.
|
||||
*
|
||||
* These exist because exactly ONE of the four (a rotated key) may ever be overridden by
|
||||
* an admin, and the UI has to tell them apart without reading prose. A client that
|
||||
* string-matches the message will eventually offer "re-trust" on a signature that simply
|
||||
* does not verify — the worst outcome this whole surface could produce.
|
||||
*/
|
||||
describe('signature failure codes', () => {
|
||||
const codeOf = async (): Promise<string | undefined> => {
|
||||
try {
|
||||
await svc.install('flight-tracker');
|
||||
return undefined; // installed — no refusal
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(RegistryError);
|
||||
return (e as RegistryError).code;
|
||||
}
|
||||
};
|
||||
|
||||
it('SIGNATURE_MISSING — was signed before, the update is unsigned', async () => {
|
||||
const k = signingKey();
|
||||
stageSignedArtifact(k.pubB64, k.sign);
|
||||
await svc.install('flight-tracker');
|
||||
|
||||
const artifact = makeArtifact({ id: 'flight-tracker', name: 'Flight', version: '1.0.0', type: 'widget', permissions: ['db:own'] });
|
||||
REGISTRY.plugins[0].versions[0].sha256 = createHash('sha256').update(artifact).digest('hex');
|
||||
delete (REGISTRY.plugins[0] as { authorPublicKey?: string }).authorPublicKey;
|
||||
delete (REGISTRY.plugins[0].versions[0] as { signature?: string }).signature;
|
||||
safeDownload.mockResolvedValue({ bytes: artifact, sha256: REGISTRY.plugins[0].versions[0].sha256 });
|
||||
|
||||
expect(await codeOf()).toBe('SIGNATURE_MISSING');
|
||||
});
|
||||
|
||||
it('SIGNATURE_INCOMPLETE — an author key with no version signature', async () => {
|
||||
const artifact = makeArtifact({ id: 'flight-tracker', name: 'Flight', version: '1.0.0', type: 'widget', permissions: ['db:own'] });
|
||||
REGISTRY.plugins[0].versions[0].sha256 = createHash('sha256').update(artifact).digest('hex');
|
||||
(REGISTRY.plugins[0] as { authorPublicKey?: string }).authorPublicKey = signingKey().pubB64;
|
||||
safeDownload.mockResolvedValue({ bytes: artifact, sha256: REGISTRY.plugins[0].versions[0].sha256 });
|
||||
|
||||
expect(await codeOf()).toBe('SIGNATURE_INCOMPLETE');
|
||||
});
|
||||
|
||||
it('SIGNATURE_KEY_CHANGED — the pinned key no longer matches the registry entry', async () => {
|
||||
const a = signingKey();
|
||||
stageSignedArtifact(a.pubB64, a.sign);
|
||||
await svc.install('flight-tracker');
|
||||
const b = signingKey();
|
||||
stageSignedArtifact(b.pubB64, b.sign); // a VALID signature — under a different key
|
||||
|
||||
expect(await codeOf()).toBe('SIGNATURE_KEY_CHANGED');
|
||||
});
|
||||
|
||||
it('SIGNATURE_INVALID — a well-formed signature that does not verify', async () => {
|
||||
const k = signingKey();
|
||||
stageSignedArtifact(k.pubB64, () => k.sign(Buffer.from('different bytes')));
|
||||
|
||||
expect(await codeOf()).toBe('SIGNATURE_INVALID');
|
||||
});
|
||||
|
||||
// verifyAuthorSignature THROWS SignatureError (not RegistryError) on a malformed key or
|
||||
// signature, and that used to escape verifySignatureAndTofu untyped. To an admin a
|
||||
// malformed signature and an invalid one are the same thing, and both are non-overridable.
|
||||
it('SIGNATURE_INVALID — a MALFORMED signature (SignatureError is mapped, not leaked)', async () => {
|
||||
const k = signingKey();
|
||||
stageSignedArtifact(k.pubB64, k.sign);
|
||||
(REGISTRY.plugins[0].versions[0] as { signature?: string }).signature = Buffer.from('nonsense').toString('base64');
|
||||
|
||||
expect(await codeOf()).toBe('SIGNATURE_INVALID');
|
||||
});
|
||||
|
||||
it('SIGNATURE_INVALID — a MALFORMED author key', async () => {
|
||||
const k = signingKey();
|
||||
stageSignedArtifact(k.pubB64, k.sign);
|
||||
(REGISTRY.plugins[0] as { authorPublicKey?: string }).authorPublicKey = Buffer.from('not-a-key').toString('base64');
|
||||
|
||||
expect(await codeOf()).toBe('SIGNATURE_INVALID');
|
||||
});
|
||||
|
||||
it('records the refusal so the admin list can keep showing it, and clears it on a later success', async () => {
|
||||
const a = signingKey();
|
||||
stageSignedArtifact(a.pubB64, a.sign);
|
||||
await svc.install('flight-tracker');
|
||||
|
||||
const b = signingKey();
|
||||
stageSignedArtifact(b.pubB64, b.sign);
|
||||
await expect(svc.install('flight-tracker')).rejects.toThrow();
|
||||
|
||||
const blocked = testDb.prepare("SELECT * FROM plugins WHERE id='flight-tracker'").get() as Record<string, unknown>;
|
||||
expect(blocked.update_block_code).toBe('SIGNATURE_KEY_CHANGED');
|
||||
expect(blocked.update_block_version).toBe('1.0.0');
|
||||
// The plugin still RUNS fine on its old code — a blocked update is not a broken
|
||||
// runtime, and marking it 'error' would make the health dot lie.
|
||||
expect(blocked.status).not.toBe('error');
|
||||
// ...and the old key is still pinned. A refused update changes nothing.
|
||||
expect(blocked.author_pubkey).toBe(a.pubB64);
|
||||
|
||||
// The author reverts to the original key: the install succeeds and the block goes.
|
||||
stageSignedArtifact(a.pubB64, a.sign);
|
||||
await svc.install('flight-tracker');
|
||||
const after = testDb.prepare("SELECT * FROM plugins WHERE id='flight-tracker'").get() as Record<string, unknown>;
|
||||
expect(after.update_block_code).toBeNull();
|
||||
expect(after.update_block_version).toBeNull();
|
||||
});
|
||||
|
||||
it('browse reports signed + the full author key (a public key — the re-trust round-trip needs it exact)', async () => {
|
||||
const k = signingKey();
|
||||
stageSignedArtifact(k.pubB64, k.sign);
|
||||
__clearRegistryCacheForTests();
|
||||
|
||||
const [entry] = await svc.browse();
|
||||
expect(entry.signed).toBe(true);
|
||||
expect(entry.authorPublicKey).toBe(k.pubB64);
|
||||
});
|
||||
|
||||
it('browse reports unsigned when the entry has no key', async () => {
|
||||
const [entry] = await svc.browse();
|
||||
expect(entry.signed).toBe(false);
|
||||
expect(entry.authorPublicKey).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Re-trust. These encode the two decisions that make this surface safe rather than merely
|
||||
* visible; if they ever go green by accident, the feature is a downgrade hole.
|
||||
*
|
||||
* D1 — a re-trust moves the pin from one VERIFIED key to another verified key. The
|
||||
* obvious implementation (clear author_pubkey to NULL and let TOFU re-pin) would
|
||||
* return the plugin to the "never been signed" state, where an UNSIGNED update is
|
||||
* legitimately accepted. That trades a key-rotation block for a silent downgrade.
|
||||
* D2 — re-trust is offered ONLY for a rotated key. A signature that doesn't verify means
|
||||
* the bytes are not what the author signed; there is no story where waving that
|
||||
* through is right. Enforced in the SERVICE, not by hiding a button.
|
||||
*/
|
||||
describe('re-trust (assertRetrustable)', () => {
|
||||
/** Install under key `a`, then have the registry offer key `b` — the rotation case. */
|
||||
async function rotateTo(b: { pubB64: string; sign: (x: Buffer) => string }, a = signingKey()) {
|
||||
stageSignedArtifact(a.pubB64, a.sign);
|
||||
await svc.install('flight-tracker');
|
||||
testDb.prepare("UPDATE plugins SET source_repo='acme/trek-flight' WHERE id='flight-tracker'").run();
|
||||
stageSignedArtifact(b.pubB64, b.sign);
|
||||
return a;
|
||||
}
|
||||
|
||||
it('accepts the rotated key the admin was shown, and hands back the entry', async () => {
|
||||
const b = signingKey();
|
||||
await rotateTo(b);
|
||||
await expect(svc.assertRetrustable('flight-tracker', b.pubB64)).resolves.toMatchObject({ authorPublicKey: b.pubB64 });
|
||||
});
|
||||
|
||||
// D1 (TOCTOU): the client echoes back the key the DIALOG rendered. If the entry has been
|
||||
// re-keyed AGAIN since then, the admin would be blessing a key they never laid eyes on.
|
||||
it('refuses when the entry was re-keyed again between the dialog and the click', async () => {
|
||||
const b = signingKey();
|
||||
await rotateTo(b);
|
||||
const c = signingKey();
|
||||
stageSignedArtifact(c.pubB64, c.sign); // the registry moved on to a THIRD key
|
||||
__clearRegistryCacheForTests();
|
||||
|
||||
await expect(svc.assertRetrustable('flight-tracker', b.pubB64)).rejects.toMatchObject({ code: 'RETRUST_KEY_MISMATCH' });
|
||||
});
|
||||
|
||||
// D2, enforced server-side. The UI hiding the button is a convenience, not the control.
|
||||
it('refuses a plugin whose key did NOT change (nothing to re-trust)', async () => {
|
||||
const a = signingKey();
|
||||
stageSignedArtifact(a.pubB64, a.sign);
|
||||
await svc.install('flight-tracker');
|
||||
testDb.prepare("UPDATE plugins SET source_repo='acme/trek-flight' WHERE id='flight-tracker'").run();
|
||||
|
||||
await expect(svc.assertRetrustable('flight-tracker', a.pubB64)).rejects.toMatchObject({ code: 'RETRUST_NOT_APPLICABLE' });
|
||||
});
|
||||
|
||||
it('refuses a sideloaded plugin (it sits outside the registry trust model entirely)', async () => {
|
||||
const b = signingKey();
|
||||
await rotateTo(b);
|
||||
testDb.prepare("UPDATE plugins SET source_repo='local:upload' WHERE id='flight-tracker'").run();
|
||||
|
||||
await expect(svc.assertRetrustable('flight-tracker', b.pubB64)).rejects.toMatchObject({ code: 'RETRUST_NOT_APPLICABLE' });
|
||||
});
|
||||
|
||||
// D1, the one that matters most: a key an admin blesses must still SIGN THE CODE it
|
||||
// ships. Re-trusting is not a way to install something unverified.
|
||||
it('refuses to install under a re-trusted key that does not actually validate the artifact', async () => {
|
||||
const b = signingKey();
|
||||
const a = await rotateTo(b);
|
||||
// The registry now offers b's key — but the signature is garbage under it.
|
||||
(REGISTRY.plugins[0].versions[0] as { signature?: string }).signature = b.sign(Buffer.from('other bytes'));
|
||||
__clearRegistryCacheForTests();
|
||||
|
||||
await expect(svc.install('flight-tracker', { version: '1.0.0', retrustKey: b.pubB64 })).rejects.toMatchObject({
|
||||
code: 'SIGNATURE_INVALID',
|
||||
});
|
||||
// The old key is STILL pinned — a refused re-trust changes nothing.
|
||||
const row = testDb.prepare("SELECT author_pubkey FROM plugins WHERE id='flight-tracker'").get() as { author_pubkey: string };
|
||||
expect(row.author_pubkey).toBe(a.pubB64);
|
||||
});
|
||||
|
||||
it('re-pins to the new key once the artifact verifies under it — and NEVER to NULL', async () => {
|
||||
const b = signingKey();
|
||||
await rotateTo(b);
|
||||
__clearRegistryCacheForTests();
|
||||
|
||||
await expect(svc.install('flight-tracker', { version: '1.0.0', retrustKey: b.pubB64 })).resolves.toMatchObject({ version: '1.0.0' });
|
||||
const row = testDb.prepare("SELECT author_pubkey, update_block_code FROM plugins WHERE id='flight-tracker'").get() as {
|
||||
author_pubkey: string | null;
|
||||
update_block_code: string | null;
|
||||
};
|
||||
expect(row.author_pubkey).toBe(b.pubB64);
|
||||
expect(row.author_pubkey).not.toBeNull();
|
||||
expect(row.update_block_code).toBeNull();
|
||||
});
|
||||
|
||||
// The retrustKey override lifts ONLY the key-change stop. Handing it an unsigned entry
|
||||
// must not open the "never been signed" path — that is the downgrade hole D1 exists for.
|
||||
it('a retrustKey cannot wave through an unsigned update of a previously-signed plugin', async () => {
|
||||
const a = signingKey();
|
||||
stageSignedArtifact(a.pubB64, a.sign);
|
||||
await svc.install('flight-tracker');
|
||||
|
||||
const artifact = makeArtifact({ id: 'flight-tracker', name: 'Flight', version: '1.0.0', type: 'widget', permissions: ['db:own'] });
|
||||
REGISTRY.plugins[0].versions[0].sha256 = createHash('sha256').update(artifact).digest('hex');
|
||||
delete (REGISTRY.plugins[0] as { authorPublicKey?: string }).authorPublicKey;
|
||||
delete (REGISTRY.plugins[0].versions[0] as { signature?: string }).signature;
|
||||
safeDownload.mockResolvedValue({ bytes: artifact, sha256: REGISTRY.plugins[0].versions[0].sha256 });
|
||||
__clearRegistryCacheForTests();
|
||||
|
||||
await expect(svc.install('flight-tracker', { version: '1.0.0', retrustKey: 'anything' })).rejects.toMatchObject({
|
||||
code: 'SIGNATURE_MISSING',
|
||||
});
|
||||
const row = testDb.prepare("SELECT author_pubkey FROM plugins WHERE id='flight-tracker'").get() as { author_pubkey: string | null };
|
||||
expect(row.author_pubkey).toBe(a.pubB64); // still pinned, never NULL
|
||||
});
|
||||
});
|
||||
|
||||
describe('PluginRegistryService.resolveVersion (latest compatible)', () => {
|
||||
const MULTI = {
|
||||
schemaVersion: 1,
|
||||
@@ -402,3 +640,190 @@ describe('PluginRegistryService.resolveVersion (latest compatible)', () => {
|
||||
await expect(svc.resolveVersion('nope')).rejects.toThrow(/not in registry/);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The install must be satisfied by the running TREK — on EVERY path.
|
||||
*
|
||||
* hostCompatible() used to be reachable only from resolveVersion(), i.e. only when the
|
||||
* caller passed a semver constraint. The admin UI's Install button passes neither a
|
||||
* version nor a constraint, so the one path an admin actually clicks was the one that
|
||||
* skipped the check entirely.
|
||||
*/
|
||||
describe('TREK-version gating on install', () => {
|
||||
const MULTI = {
|
||||
schemaVersion: 1,
|
||||
plugins: [{
|
||||
id: 'multi', name: 'Multi', author: 'a', description: 'many versions', repo: 'a/b', type: 'integration',
|
||||
versions: [
|
||||
{ version: '2.1.0', gitTag: 'v2.1.0', commitSha: 'a'.repeat(40), downloadUrl: 'https://codeload.github.com/a/b/tar.gz/x', sha256: '', minTrekVersion: '3.4.0', trek: '>=3.4.0 <4.0.0' },
|
||||
{ version: '2.0.0', gitTag: 'v2.0.0', commitSha: 'a'.repeat(40), downloadUrl: 'https://codeload.github.com/a/b/tar.gz/y', sha256: '', minTrekVersion: '3.2.0', trek: '>=3.2.0 <4.0.0' },
|
||||
],
|
||||
}],
|
||||
};
|
||||
const stubMulti = () => { __clearRegistryCacheForTests(); vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => MULTI }) as unknown as Response)); };
|
||||
|
||||
afterEach(() => { delete process.env.APP_VERSION; });
|
||||
|
||||
/** Stage `version`'s artifact so a successful install has real bytes to fetch. */
|
||||
const stageMulti = (version: string, idx: number) => {
|
||||
const artifact = makeArtifact({ id: 'multi', name: 'Multi', version, type: 'integration', trek: MULTI.plugins[0].versions[idx].trek });
|
||||
const sha = createHash('sha256').update(artifact).digest('hex');
|
||||
MULTI.plugins[0].versions[idx].sha256 = sha;
|
||||
safeDownload.mockResolvedValue({ bytes: artifact, sha256: sha });
|
||||
};
|
||||
|
||||
it('install-latest takes the newest version this TREK can RUN, not the newest published', async () => {
|
||||
// 2.1.0 is versions[0] and needs >=3.4.0. The old code fetched it without asking; now
|
||||
// the same "install latest" the admin's button sends resolves to 2.0.0 instead.
|
||||
process.env.APP_VERSION = '3.3.0'; stubMulti();
|
||||
stageMulti('2.0.0', 1);
|
||||
await expect(svc.install('multi')).resolves.toMatchObject({ id: 'multi', version: '2.0.0' });
|
||||
expect(safeDownload).toHaveBeenCalledWith(MULTI.plugins[0].versions[1].downloadUrl, expect.any(Number));
|
||||
});
|
||||
|
||||
it('refuses install-latest outright when NO published version fits', async () => {
|
||||
process.env.APP_VERSION = '3.0.0'; stubMulti(); // both versions need >=3.2.0
|
||||
await expect(svc.install('multi')).rejects.toMatchObject({ code: 'TREK_VERSION_INCOMPATIBLE' });
|
||||
expect(safeDownload).not.toHaveBeenCalled(); // refused BEFORE a byte is fetched
|
||||
});
|
||||
|
||||
it('refuses an explicitly-pinned version this TREK cannot run', async () => {
|
||||
process.env.APP_VERSION = '3.3.0'; stubMulti();
|
||||
await expect(svc.install('multi', { version: '2.1.0' })).rejects.toMatchObject({ code: 'TREK_VERSION_INCOMPATIBLE' });
|
||||
expect(safeDownload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('honours an EXCLUSIVE upper bound, which minTrekVersion alone cannot express', async () => {
|
||||
// Both versions declare "<4.0.0" and neither carries a maxTrekVersion — under the old
|
||||
// min-only check TREK 4 looked compatible with everything.
|
||||
process.env.APP_VERSION = '4.0.0'; stubMulti();
|
||||
await expect(svc.install('multi')).rejects.toMatchObject({ code: 'TREK_VERSION_INCOMPATIBLE' });
|
||||
});
|
||||
|
||||
it('reports the newest version this TREK CAN run, so the UI can offer it', async () => {
|
||||
process.env.APP_VERSION = '3.3.0'; stubMulti();
|
||||
const [item] = await svc.browse(true);
|
||||
expect(item).toMatchObject({ id: 'multi', latest: '2.1.0', compatible: false, latestCompatible: '2.0.0', hostVersion: '3.3.0' });
|
||||
});
|
||||
|
||||
it('reports plain compatibility when the latest version fits', async () => {
|
||||
process.env.APP_VERSION = '3.5.0'; stubMulti();
|
||||
const [item] = await svc.browse(true);
|
||||
expect(item).toMatchObject({ compatible: true, latestCompatible: '2.1.0' });
|
||||
});
|
||||
|
||||
it('tolerates a legacy entry with NO lower bound at all (minTrekVersion: null)', async () => {
|
||||
// The schema allows a null floor, so nothing may assume a string here: the legacy check
|
||||
// must skip the bound rather than compare against it, and the "requires TREK …" message
|
||||
// must not interpolate a missing bound as the word "null".
|
||||
process.env.APP_VERSION = '3.3.0';
|
||||
__clearRegistryCacheForTests();
|
||||
const noFloor = {
|
||||
schemaVersion: 1,
|
||||
plugins: [{
|
||||
id: 'nofloor', name: 'NoFloor', author: 'a', description: 'no lower bound', repo: 'a/b', type: 'integration',
|
||||
versions: [{ version: '1.0.0', gitTag: 'v1.0.0', commitSha: 'a'.repeat(40), downloadUrl: 'https://codeload.github.com/a/b/tar.gz/x', sha256: '', minTrekVersion: null, maxTrekVersion: '3.0.0' }],
|
||||
}],
|
||||
};
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => noFloor }) as unknown as Response));
|
||||
|
||||
// Only the ceiling applies, and TREK 3.3 is past it — refused, with a readable reason.
|
||||
const err = await svc.install('nofloor').catch((e) => e);
|
||||
expect(err).toMatchObject({ code: 'TREK_VERSION_INCOMPATIBLE' });
|
||||
expect(err.message).toContain('<=3.0.0');
|
||||
expect(err.message).not.toContain('null');
|
||||
});
|
||||
|
||||
it('falls back to the legacy min/max bounds for an entry published without a range', async () => {
|
||||
process.env.APP_VERSION = '3.1.0'; // the fixture entry declares minTrekVersion 3.2.0, no trek
|
||||
__clearRegistryCacheForTests();
|
||||
await expect(svc.install('flight-tracker')).rejects.toMatchObject({ code: 'TREK_VERSION_INCOMPATIBLE' });
|
||||
});
|
||||
|
||||
it("re-gates on the ARTIFACT's own range — the index metadata is only a pre-download filter", async () => {
|
||||
// The registry index says 3.2.0+, but the manifest inside the tarball says it stops at
|
||||
// 3.3.0. The bytes are what will run, so the bytes get the last word.
|
||||
process.env.APP_VERSION = '3.9.0';
|
||||
__clearRegistryCacheForTests();
|
||||
const artifact = makeArtifact({ id: 'flight-tracker', name: 'Flight', version: '1.0.0', type: 'widget', trek: '>=3.2.0 <3.4.0' });
|
||||
const sha = createHash('sha256').update(artifact).digest('hex');
|
||||
REGISTRY.plugins[0].versions[0].sha256 = sha;
|
||||
safeDownload.mockResolvedValue({ bytes: artifact, sha256: sha });
|
||||
await expect(svc.install('flight-tracker')).rejects.toMatchObject({ code: 'TREK_VERSION_INCOMPATIBLE' });
|
||||
expect(fs.existsSync(path.join(codeRoot, 'flight-tracker'))).toBe(false); // nothing moved into place
|
||||
});
|
||||
|
||||
it('refuses an artifact that declares no range at all', async () => {
|
||||
__clearRegistryCacheForTests();
|
||||
const artifact = makeArtifact({ id: 'flight-tracker', name: 'Flight', version: '1.0.0', type: 'widget', trek: undefined });
|
||||
const sha = createHash('sha256').update(artifact).digest('hex');
|
||||
REGISTRY.plugins[0].versions[0].sha256 = sha;
|
||||
safeDownload.mockResolvedValue({ bytes: artifact, sha256: sha });
|
||||
await expect(svc.install('flight-tracker')).rejects.toThrow(/missing "trek"/);
|
||||
});
|
||||
|
||||
describe('sideload', () => {
|
||||
it('refuses an uploaded archive this TREK cannot run, and cleans up staging', async () => {
|
||||
process.env.APP_VERSION = '3.9.0';
|
||||
const bytes = makeArtifact({ id: 'my-upload', name: 'Up', version: '1.0.0', type: 'integration', trek: '>=3.0.0 <3.5.0' });
|
||||
expect(() => svc.stageUpload(bytes)).toThrow(expect.objectContaining({ code: 'TREK_VERSION_INCOMPATIBLE' }));
|
||||
const staging = path.join(dataRoot, '.staging');
|
||||
// The rejected upload leaves nothing extracted behind.
|
||||
expect(fs.existsSync(staging) ? fs.readdirSync(staging) : []).toEqual([]);
|
||||
});
|
||||
|
||||
it('refuses an uploaded archive that declares no range', () => {
|
||||
const bytes = makeArtifact({ id: 'my-upload', name: 'Up', version: '1.0.0', type: 'integration', trek: undefined });
|
||||
expect(() => svc.stageUpload(bytes)).toThrow(/missing "trek"/);
|
||||
});
|
||||
});
|
||||
|
||||
it('installWithDependencies installs the version it RESOLVED, not versions[0]', async () => {
|
||||
// It resolved a compatible version and then called install() with the range (or with
|
||||
// nothing, for the root), which made install() re-pick — landing on versions[0] and
|
||||
// discarding the compatible choice entirely.
|
||||
process.env.APP_VERSION = '3.3.0'; stubMulti();
|
||||
const artifact = makeArtifact({ id: 'multi', name: 'Multi', version: '2.0.0', type: 'integration', trek: '>=3.2.0 <4.0.0' });
|
||||
const sha = createHash('sha256').update(artifact).digest('hex');
|
||||
MULTI.plugins[0].versions[1].sha256 = sha;
|
||||
safeDownload.mockResolvedValue({ bytes: artifact, sha256: sha });
|
||||
|
||||
const res = await svc.installWithDependencies('multi');
|
||||
expect(res.installed).toEqual(['multi']);
|
||||
expect(safeDownload).toHaveBeenCalledWith(MULTI.plugins[0].versions[1].downloadUrl, expect.any(Number)); // 2.0.0, not 2.1.0
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Leaving the registry trust model (sideload / dev-link) must take the update-block with it.
|
||||
*
|
||||
* A block describes a refused REGISTRY update. Once an admin hands TREK the bytes directly,
|
||||
* the running code is whatever they uploaded — a warning about an author's signing key says
|
||||
* nothing about it, and the row would insist an update was blocked over a key that no longer
|
||||
* applies to the code in front of them.
|
||||
*/
|
||||
describe('an update block does not outlive the registry relationship', () => {
|
||||
it('sideloading clears a recorded block (and the pinned key)', async () => {
|
||||
const a = signingKey();
|
||||
stageSignedArtifact(a.pubB64, a.sign);
|
||||
await svc.install('flight-tracker');
|
||||
|
||||
const b = signingKey();
|
||||
stageSignedArtifact(b.pubB64, b.sign);
|
||||
await expect(svc.install('flight-tracker')).rejects.toThrow(); // rotated key → blocked
|
||||
expect(
|
||||
(testDb.prepare("SELECT update_block_code FROM plugins WHERE id='flight-tracker'").get() as { update_block_code: string })
|
||||
.update_block_code,
|
||||
).toBe('SIGNATURE_KEY_CHANGED');
|
||||
|
||||
// The admin now uploads the plugin by hand.
|
||||
const upload = makeArtifact({ id: 'flight-tracker', name: 'Flight', version: '9.9.9', type: 'widget', permissions: ['db:own'] });
|
||||
svc.commitUpload(svc.stageUpload(upload));
|
||||
|
||||
const row = testDb.prepare("SELECT source_repo, author_pubkey, update_block_code, update_block_version FROM plugins WHERE id='flight-tracker'").get() as Record<string, unknown>;
|
||||
expect(row.source_repo).toBe('local:upload');
|
||||
expect(row.author_pubkey).toBeNull(); // out of the trust model — deliberate, and badged
|
||||
expect(row.update_block_code).toBeNull();
|
||||
expect(row.update_block_version).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* Unit tests for MCP sessionManager — SESS-001 to SESS-010.
|
||||
* Covers revokeUserSessions and revokeUserSessionsForClient.
|
||||
* Unit tests for MCP sessionManager — SESS-001 to SESS-016.
|
||||
* Covers revokeUserSessions, revokeUserSessionsForClient and evictOldestSessionForUser.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { sessions, revokeUserSessions, revokeUserSessionsForClient, McpSession } from '../../../src/mcp/sessionManager';
|
||||
import { sessions, revokeUserSessions, revokeUserSessionsForClient, evictOldestSessionForUser, McpSession } from '../../../src/mcp/sessionManager';
|
||||
|
||||
function makeSession(overrides: Partial<McpSession> = {}): McpSession {
|
||||
return {
|
||||
@@ -119,3 +119,62 @@ describe('revokeUserSessionsForClient', () => {
|
||||
expect(sessions.has('sid-1')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('evictOldestSessionForUser', () => {
|
||||
it('SESS-011: evicts the session with the lowest lastActivity and returns its id', () => {
|
||||
sessions.set('warm', makeSession({ userId: 1, lastActivity: 3_000 }));
|
||||
sessions.set('cold', makeSession({ userId: 1, lastActivity: 1_000 }));
|
||||
sessions.set('mid', makeSession({ userId: 1, lastActivity: 2_000 }));
|
||||
|
||||
expect(evictOldestSessionForUser(1)).toBe('cold');
|
||||
|
||||
expect(sessions.has('cold')).toBe(false);
|
||||
expect(sessions.has('mid')).toBe(true);
|
||||
expect(sessions.has('warm')).toBe(true);
|
||||
});
|
||||
|
||||
it('SESS-012: never evicts another user\'s session, even if it is colder', () => {
|
||||
sessions.set('other-user-coldest', makeSession({ userId: 2, lastActivity: 1 }));
|
||||
sessions.set('target', makeSession({ userId: 1, lastActivity: 9_000 }));
|
||||
|
||||
expect(evictOldestSessionForUser(1)).toBe('target');
|
||||
|
||||
expect(sessions.has('target')).toBe(false);
|
||||
expect(sessions.has('other-user-coldest')).toBe(true);
|
||||
});
|
||||
|
||||
it('SESS-013: closes both the server and the transport of the evicted session', () => {
|
||||
const s = makeSession({ userId: 1, lastActivity: 1_000 });
|
||||
sessions.set('sid-1', s);
|
||||
|
||||
evictOldestSessionForUser(1);
|
||||
|
||||
expect(s.server.close).toHaveBeenCalledOnce();
|
||||
expect(s.transport.close).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('SESS-014: returns null when the user has no sessions', () => {
|
||||
sessions.set('sid-1', makeSession({ userId: 2 }));
|
||||
|
||||
expect(evictOldestSessionForUser(1)).toBeNull();
|
||||
expect(sessions.has('sid-1')).toBe(true);
|
||||
});
|
||||
|
||||
it('SESS-015: still drops the map entry when server.close() throws', () => {
|
||||
const s = makeSession({ userId: 1 });
|
||||
(s.server.close as ReturnType<typeof vi.fn>).mockImplementation(() => { throw new Error('close failed'); });
|
||||
sessions.set('sid-1', s);
|
||||
|
||||
expect(evictOldestSessionForUser(1)).toBe('sid-1');
|
||||
expect(sessions.has('sid-1')).toBe(false);
|
||||
});
|
||||
|
||||
it('SESS-016: still drops the map entry when transport.close() throws', () => {
|
||||
const s = makeSession({ userId: 1 });
|
||||
(s.transport.close as ReturnType<typeof vi.fn>).mockImplementation(() => { throw new Error('transport error'); });
|
||||
sessions.set('sid-1', s);
|
||||
|
||||
expect(evictOldestSessionForUser(1)).toBe('sid-1');
|
||||
expect(sessions.has('sid-1')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,7 +27,7 @@ beforeEach(() => {
|
||||
db = new Database(':memory:');
|
||||
db.exec(`
|
||||
CREATE TABLE plugins (id TEXT PRIMARY KEY, name TEXT, description TEXT, type TEXT, icon TEXT, version TEXT,
|
||||
api_version INTEGER, min_trek_version TEXT, permissions TEXT, capabilities TEXT DEFAULT '{}', dependencies TEXT DEFAULT '{}', operator_egress INTEGER DEFAULT 0, granted_permissions TEXT, status TEXT, config TEXT, updated_at TEXT);
|
||||
api_version INTEGER, min_trek_version TEXT, trek_range TEXT, permissions TEXT, capabilities TEXT DEFAULT '{}', dependencies TEXT DEFAULT '{}', operator_egress INTEGER DEFAULT 0, granted_permissions TEXT, status TEXT, config TEXT, updated_at TEXT);
|
||||
CREATE TABLE plugin_settings_fields (plugin_id TEXT, field_key TEXT, label TEXT, input_type TEXT, placeholder TEXT, hint TEXT,
|
||||
required INTEGER, secret INTEGER, scope TEXT, options TEXT, oauth_config TEXT, sort_order INTEGER);
|
||||
CREATE TABLE plugin_actions (plugin_id TEXT, action_key TEXT, label TEXT, hint TEXT, danger INTEGER, sort_order INTEGER,
|
||||
@@ -125,4 +125,31 @@ describe('discoverPlugins', () => {
|
||||
process.env.TREK_PLUGINS_DIR = path.join(codeRoot, 'does-not-exist');
|
||||
expect(discoverPlugins(db)).toEqual({ discovered: [], skipped: [] });
|
||||
});
|
||||
|
||||
describe('the TREK range', () => {
|
||||
it('persists the range and its lower bound', () => {
|
||||
writePlugin('ranged', { trek: '>=3.2.0 <4.0.0' });
|
||||
discoverPlugins(db);
|
||||
expect(db.prepare("SELECT trek_range, min_trek_version FROM plugins WHERE id='ranged'").get())
|
||||
.toMatchObject({ trek_range: '>=3.2.0 <4.0.0', min_trek_version: '3.2.0' });
|
||||
});
|
||||
|
||||
it('still registers a plugin that declares NO range — it must not vanish', () => {
|
||||
// Discovery is a reconciler, not a gate: it only logs and skips on a throw and never
|
||||
// touches the plugins row, so refusing here would leave an existing plugin's stale
|
||||
// enabled=1 row to be spawned by the next boot — invisible AND running. The row is
|
||||
// registered with a null range and the activation gate refuses it (TREK_VERSION_UNKNOWN).
|
||||
writePlugin('rangeless', {});
|
||||
expect(discoverPlugins(db).discovered).toEqual(['rangeless']);
|
||||
expect(db.prepare("SELECT trek_range FROM plugins WHERE id='rangeless'").get()).toMatchObject({ trek_range: null });
|
||||
});
|
||||
|
||||
it('refreshes the range on re-discovery, so a plugin that narrowed its support is caught', () => {
|
||||
writePlugin('shrink', { trek: '>=3.0.0' });
|
||||
discoverPlugins(db);
|
||||
writePlugin('shrink', { trek: '>=3.0.0 <3.1.0' });
|
||||
discoverPlugins(db);
|
||||
expect(db.prepare("SELECT trek_range FROM plugins WHERE id='shrink'").get()).toMatchObject({ trek_range: '>=3.0.0 <3.1.0' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||
import {
|
||||
hostVersion,
|
||||
normalizedHost,
|
||||
isValidTrekRange,
|
||||
minTrekOf,
|
||||
hostSatisfies,
|
||||
__resetHostWarningForTests,
|
||||
} from '../../../src/nest/plugins/install/host-compat';
|
||||
|
||||
/**
|
||||
* The TREK-version gate (#plugins). Every install front door and the activation gate
|
||||
* answer "can this plugin run here?" through these functions, so their edges are the
|
||||
* edges of the whole feature.
|
||||
*/
|
||||
|
||||
const APP_VERSION = process.env.APP_VERSION;
|
||||
afterEach(() => {
|
||||
if (APP_VERSION === undefined) delete process.env.APP_VERSION;
|
||||
else process.env.APP_VERSION = APP_VERSION;
|
||||
__resetHostWarningForTests();
|
||||
});
|
||||
|
||||
describe('isValidTrekRange', () => {
|
||||
it('accepts the ranges a plugin actually declares', () => {
|
||||
for (const r of ['>=3.2.0 <4.0.0', '^3.2.0', '~3.2', '>=3', '<4.0.0', '3.x', '*']) {
|
||||
expect(isValidTrekRange(r), r).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a range no version can ever satisfy — validRange() alone would accept it', () => {
|
||||
// Syntactically fine, semantically empty. A plugin declaring this would be
|
||||
// uninstallable everywhere with nothing to tell its author why.
|
||||
expect(isValidTrekRange('>=4.0.0 <3.0.0')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects junk without letting semver.minVersion throw out of it', () => {
|
||||
for (const r of ['latest', 'v3-ish', '3.2+', '', ' ', undefined, null, 42, {}]) {
|
||||
expect(isValidTrekRange(r), String(r)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('minTrekOf', () => {
|
||||
it('reads the lower bound off the RANGE, not off the first version-shaped substring', () => {
|
||||
// The regression this replaces: the old regex took the first X.Y.Z in the string, so
|
||||
// "<4.0.0" reported a *minimum* of 4.0.0 — the exact inverse of what the plugin says.
|
||||
expect(minTrekOf('<4.0.0')).toBeUndefined(); // lower bound is 0.0.0 → nothing to show
|
||||
expect(minTrekOf('>=3.2.0 <4.0.0')).toBe('3.2.0');
|
||||
expect(minTrekOf('^3.2.0')).toBe('3.2.0');
|
||||
expect(minTrekOf('~3.2')).toBe('3.2.0');
|
||||
expect(minTrekOf('>=3')).toBe('3.0.0');
|
||||
expect(minTrekOf('>=3.2.0-beta.1')).toBe('3.2.0-beta.1');
|
||||
});
|
||||
|
||||
it('has no lower bound to show for an unbounded range', () => {
|
||||
expect(minTrekOf('*')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('hostSatisfies', () => {
|
||||
it('admits a host inside the range and refuses one outside it', () => {
|
||||
expect(hostSatisfies('>=3.2.0 <4.0.0', '3.3.0')).toBe(true);
|
||||
expect(hostSatisfies('>=3.2.0 <4.0.0', '3.2.0')).toBe(true);
|
||||
expect(hostSatisfies('>=3.2.0 <4.0.0', '3.1.9')).toBe(false);
|
||||
expect(hostSatisfies('>=3.2.0 <4.0.0', '4.0.0')).toBe(false); // the exclusive upper bound
|
||||
});
|
||||
|
||||
it('treats a prerelease host as its target release — both edges matter', () => {
|
||||
// Plain semver would fail 3.4.0-rc.1 against a range that names no prerelease, which
|
||||
// would make EVERY plugin incompatible the moment TREK shipped an rc.
|
||||
expect(hostSatisfies('>=3.2.0 <4.0.0', '3.4.0-rc.1')).toBe(true);
|
||||
// And includePrerelease would order 4.0.0-rc.1 BEFORE 4.0.0, so it would satisfy
|
||||
// "<4.0.0" — loading a plugin that disclaims TREK 4 onto a build that already carries
|
||||
// TREK 4's breaking changes. An rc of 4.0 IS 4.0 for compatibility purposes.
|
||||
expect(hostSatisfies('>=3.2.0 <4.0.0', '4.0.0-rc.1')).toBe(false);
|
||||
expect(hostSatisfies('>=4.0.0', '4.0.0-rc.1')).toBe(true);
|
||||
});
|
||||
|
||||
it('never blocks when the host version is not semver at all (APP_VERSION=dev)', () => {
|
||||
// The Docker build ARG defaults to the literal "dev". An unversioned build must stay
|
||||
// fully usable rather than refusing every plugin it has.
|
||||
process.env.APP_VERSION = 'dev';
|
||||
expect(normalizedHost()).toBeNull();
|
||||
expect(hostSatisfies('>=3.2.0 <4.0.0')).toBe(true);
|
||||
expect(hostSatisfies(null)).toBe(true);
|
||||
});
|
||||
|
||||
it('warns once — and only once — about an unparseable host, so a misconfig is visible', () => {
|
||||
process.env.APP_VERSION = 'nonsense';
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
normalizedHost();
|
||||
normalizedHost();
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
expect(warn.mock.calls[0][0]).toMatch(/not a semver version/);
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
it('refuses an undeclared or unsatisfiable range rather than waving it through', () => {
|
||||
// The caller decides what an absent range MEANS (reject at install, "unknown" at
|
||||
// activation) — it must not pass by accident here.
|
||||
expect(hostSatisfies(null, '3.3.0')).toBe(false);
|
||||
expect(hostSatisfies('', '3.3.0')).toBe(false);
|
||||
expect(hostSatisfies('>=4.0.0 <3.0.0', '3.3.0')).toBe(false);
|
||||
});
|
||||
|
||||
it('reads the running version from APP_VERSION, falling back to the package version', () => {
|
||||
process.env.APP_VERSION = '3.9.1';
|
||||
expect(hostVersion()).toBe('3.9.1');
|
||||
expect(hostSatisfies('>=3.2.0 <4.0.0')).toBe(true);
|
||||
expect(hostSatisfies('>=4.0.0')).toBe(false);
|
||||
|
||||
delete process.env.APP_VERSION;
|
||||
expect(hostVersion()).toMatch(/^\d+\.\d+\.\d+/); // package.json
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,41 @@ describe('parseManifest', () => {
|
||||
expect(parseManifest({ ...base, trek: '>=3.2.0 <4.0.0' }).minTrekVersion).toBe('3.2.0');
|
||||
});
|
||||
|
||||
describe('the trek range', () => {
|
||||
it('normalizes a valid range onto trekRange — what the host actually gates on', () => {
|
||||
const m = parseManifest({ ...base, trek: '>=3.2.0 <4.0.0' });
|
||||
expect(m.trekRange).toBe('>=3.2.0 <4.0.0');
|
||||
});
|
||||
|
||||
it('reads the lower bound off the range, not off its first version-shaped substring', () => {
|
||||
// "<4.0.0" has a first X.Y.Z of 4.0.0 but a lower bound of 0.0.0. The old regex
|
||||
// published 4.0.0 as the MINIMUM — the exact inverse of what the plugin declared.
|
||||
expect(parseManifest({ ...base, trek: '<4.0.0' }).minTrekVersion).toBeUndefined();
|
||||
expect(parseManifest({ ...base, trek: '>=3' }).minTrekVersion).toBe('3.0.0');
|
||||
});
|
||||
|
||||
it('stays lenient by default — discovery must still register a plugin with no range', () => {
|
||||
// Discovery only logs and skips on a throw; it never touches the plugins row, so a
|
||||
// strict parse there would leave a stale enabled=1 row that the next boot spawns
|
||||
// anyway. The activation gate refuses an undeclared range instead.
|
||||
const m = parseManifest({ ...base });
|
||||
expect(m.trekRange).toBeNull();
|
||||
expect(m.minTrekVersion).toBeUndefined();
|
||||
});
|
||||
|
||||
it('drops an unparseable range to null rather than throwing, in lenient mode', () => {
|
||||
expect(parseManifest({ ...base, trek: '3.2+' }).trekRange).toBeNull();
|
||||
});
|
||||
|
||||
it('requires a satisfiable range at the install front doors', () => {
|
||||
expect(() => parseManifest({ ...base }, { requireTrek: true })).toThrow(/missing "trek"/);
|
||||
expect(() => parseManifest({ ...base, trek: '3.2+' }, { requireTrek: true })).toThrow(ManifestError);
|
||||
// Syntactically valid, semantically empty — no TREK could ever run it.
|
||||
expect(() => parseManifest({ ...base, trek: '>=4.0.0 <3.0.0' }, { requireTrek: true })).toThrow(/satisfiable/);
|
||||
expect(parseManifest({ ...base, trek: '^3.2.0' }, { requireTrek: true }).trekRange).toBe('^3.2.0');
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts the trip-page type (mounts as a tab inside the trip planner)', () => {
|
||||
expect(parseManifest({ ...base, type: 'trip-page' }).type).toBe('trip-page');
|
||||
});
|
||||
|
||||
@@ -42,8 +42,12 @@ function fakeRes() {
|
||||
}
|
||||
const req = (p: string, host?: string) => ({ params: { path: p }, get: (h: string) => (h.toLowerCase() === 'host' ? host : undefined) }) as never;
|
||||
|
||||
function runtime(active = true, hosts: string[] = []): PluginRuntimeService {
|
||||
return { isActive: vi.fn(() => active), outboundHostsOf: vi.fn(() => hosts) } as unknown as PluginRuntimeService;
|
||||
function runtime(active = true, hosts: string[] = [], operatorHosts: string[] = []): PluginRuntimeService {
|
||||
return {
|
||||
isActive: vi.fn(() => active),
|
||||
outboundHostsOf: vi.fn(() => hosts),
|
||||
operatorEgressHosts: vi.fn(() => operatorHosts),
|
||||
} as unknown as PluginRuntimeService;
|
||||
}
|
||||
|
||||
describe('PluginFrameController', () => {
|
||||
@@ -60,6 +64,35 @@ describe('PluginFrameController', () => {
|
||||
expect(res.headers['X-Content-Type-Options']).toBe('nosniff');
|
||||
});
|
||||
|
||||
// An operatorEgress plugin's hosts are supplied by the ADMIN after install, so they are not
|
||||
// in the manifest grants. activate() unions them into the child's egress guard; the frame
|
||||
// must match, or a plugin with a UI can call the host from its server but not its iframe.
|
||||
it('includes admin-supplied operatorEgress hosts in connect-src', () => {
|
||||
const res = fakeRes();
|
||||
new PluginFrameController(runtime(true, ['api.weather.com'], ['gotify.home.lan'])).serve('widget', req(''), res as never);
|
||||
const csp = res.headers['Content-Security-Policy'];
|
||||
expect(csp).toContain('https://api.weather.com');
|
||||
expect(csp).toContain('https://gotify.home.lan');
|
||||
});
|
||||
|
||||
it('does not duplicate a host granted in the manifest AND added by the admin', () => {
|
||||
const res = fakeRes();
|
||||
new PluginFrameController(runtime(true, ['ntfy.sh'], ['ntfy.sh'])).serve('widget', req(''), res as never);
|
||||
const csp = res.headers['Content-Security-Policy'];
|
||||
expect(csp.match(/https:\/\/ntfy\.sh/g)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('refuses to interpolate a malformed operator host into the CSP', () => {
|
||||
const res = fakeRes();
|
||||
// The admin writer validates hosts, but connect-src is the last line of defence: a token
|
||||
// with a space or a bare * would widen the whole policy.
|
||||
new PluginFrameController(runtime(true, [], ['evil.com https://*', '*'])).serve('widget', req(''), res as never);
|
||||
const csp = res.headers['Content-Security-Policy'];
|
||||
expect(csp).toContain("connect-src 'self'");
|
||||
expect(csp).not.toContain('evil.com');
|
||||
expect(csp).not.toContain('*');
|
||||
});
|
||||
|
||||
it('allows the plugin its own static assets when the Host header is clean', () => {
|
||||
const res = fakeRes();
|
||||
new PluginFrameController(runtime(true)).serve('widget', req('', 'trek.example.com:8443'), res as never);
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* The admin plugin controller's signature surface (#plugins): the machine-readable
|
||||
* refusal code reaching the WIRE, and the re-trust endpoint.
|
||||
*
|
||||
* The code is tested through the real TrekExceptionFilter rather than on the thrown
|
||||
* object, because that filter is the one thing standing between the code and the client
|
||||
* — and the whole design rests on the client being able to distinguish "the author
|
||||
* rotated their key" (overridable) from "these bytes are not what the author signed"
|
||||
* (never overridable) WITHOUT string-matching prose. If the code silently died in the
|
||||
* envelope, every test on the thrown object would still pass and the UI would be
|
||||
* guessing.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { HttpException, type ArgumentsHost } from '@nestjs/common';
|
||||
|
||||
vi.mock('../../../src/db/database', () => ({ db: {}, canAccessTrip: () => undefined }));
|
||||
|
||||
import { PluginsController } from '../../../src/nest/plugins/plugins.controller';
|
||||
import { PluginRuntimeService } from '../../../src/nest/plugins/plugin-runtime.service';
|
||||
import { PluginRegistryService, RegistryError } from '../../../src/nest/plugins/registry/registry.service';
|
||||
import { PluginsService } from '../../../src/nest/plugins/plugins.service';
|
||||
import { TrekExceptionFilter } from '../../../src/nest/common/trek-exception.filter';
|
||||
|
||||
const ADMIN = { id: 1 } as { id: number };
|
||||
const REQ = { headers: {}, socket: {} } as never;
|
||||
|
||||
/** Run an exception through the real global filter and report what the client would see. */
|
||||
function throughFilter(e: unknown): { status: number; body: unknown } {
|
||||
let status = 0;
|
||||
let body: unknown;
|
||||
const res = { status: (s: number) => ((status = s), res), json: (b: unknown) => ((body = b), res) };
|
||||
new TrekExceptionFilter().catch(e, { switchToHttp: () => ({ getResponse: () => res }) } as unknown as ArgumentsHost);
|
||||
return { status, body };
|
||||
}
|
||||
|
||||
/** Drive a controller call and return the wire response of its failure. */
|
||||
async function wireFailure(fn: () => Promise<unknown>): Promise<{ status: number; body: unknown }> {
|
||||
try {
|
||||
await fn();
|
||||
throw new Error('expected the call to fail');
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(HttpException);
|
||||
return throughFilter(e);
|
||||
}
|
||||
}
|
||||
|
||||
let runtime: PluginRuntimeService;
|
||||
let registry: PluginRegistryService;
|
||||
let controller: PluginsController;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.TREK_PLUGINS_ENABLED = 'true';
|
||||
runtime = { update: vi.fn(), retrust: vi.fn() } as unknown as PluginRuntimeService;
|
||||
registry = { install: vi.fn() } as unknown as PluginRegistryService;
|
||||
controller = new PluginsController({} as PluginsService, runtime, registry);
|
||||
});
|
||||
|
||||
describe('signature refusal codes survive to the client', () => {
|
||||
it('POST :id/update carries { error, code } through the exception filter', async () => {
|
||||
vi.mocked(runtime.update).mockRejectedValue(
|
||||
new RegistryError("the plugin's author signing key changed since it was installed", 'SIGNATURE_KEY_CHANGED'),
|
||||
);
|
||||
|
||||
const { status, body } = await wireFailure(() => controller.update('flight-tracker'));
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual({
|
||||
error: "the plugin's author signing key changed since it was installed",
|
||||
code: 'SIGNATURE_KEY_CHANGED',
|
||||
});
|
||||
});
|
||||
|
||||
it('POST install carries { error, code } through the exception filter', async () => {
|
||||
vi.mocked(registry.install).mockRejectedValue(new RegistryError('author signature verification failed', 'SIGNATURE_INVALID'));
|
||||
|
||||
const { status, body } = await wireFailure(() => controller.install({ id: 'flight-tracker' }));
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual({ error: 'author signature verification failed', code: 'SIGNATURE_INVALID' });
|
||||
});
|
||||
|
||||
// A SIGNATURE_INVALID reaching the client as a bare { error } would leave the UI unable
|
||||
// to tell it apart from a rotation — and a UI that guesses will eventually offer
|
||||
// "re-trust" on bytes the author never signed.
|
||||
it('an INVALID signature arrives with its own code, not the re-trustable one', async () => {
|
||||
vi.mocked(runtime.update).mockRejectedValue(new RegistryError('author signature verification failed', 'SIGNATURE_INVALID'));
|
||||
|
||||
const { body } = await wireFailure(() => controller.update('flight-tracker'));
|
||||
expect((body as { code: string }).code).toBe('SIGNATURE_INVALID');
|
||||
expect((body as { code: string }).code).not.toBe('SIGNATURE_KEY_CHANGED');
|
||||
});
|
||||
|
||||
it('an ordinary failure with no code still emits the plain { error } envelope', async () => {
|
||||
vi.mocked(runtime.update).mockRejectedValue(new Error('network unreachable'));
|
||||
|
||||
const { status, body } = await wireFailure(() => controller.update('flight-tracker'));
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual({ error: 'network unreachable' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST :id/retrust', () => {
|
||||
it('re-pins AND installs in one call, returning the update result', async () => {
|
||||
vi.mocked(runtime.retrust).mockResolvedValue({ version: '2.0.0', activated: true, newPermissions: [], newEgress: [] });
|
||||
|
||||
const res = await controller.retrust('flight-tracker', { version: '2.0.0', publicKey: 'NEWKEY' }, ADMIN, REQ);
|
||||
|
||||
expect(res).toEqual({ version: '2.0.0', activated: true, newPermissions: [], newEgress: [] });
|
||||
// The version + the full key the admin was SHOWN are both forwarded: the version
|
||||
// because the artifact bytes must be fetched to verify them under the new key, the
|
||||
// full key (not a fingerprint) because the server's equality check must be exact.
|
||||
expect(runtime.retrust).toHaveBeenCalledWith('flight-tracker', '2.0.0', 'NEWKEY', expect.objectContaining({ userId: 1 }));
|
||||
});
|
||||
|
||||
it('requires the version and the public key', async () => {
|
||||
await expect(controller.retrust('flight-tracker', { publicKey: 'K' }, ADMIN, REQ)).rejects.toThrow(HttpException);
|
||||
await expect(controller.retrust('flight-tracker', { version: '2.0.0' }, ADMIN, REQ)).rejects.toThrow(HttpException);
|
||||
expect(runtime.retrust).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// D2 at the HTTP boundary: the service refuses anything but a changed key, and the
|
||||
// refusal reaches the client with its code intact. Hiding the button in the UI is a
|
||||
// convenience — THIS is the control.
|
||||
it('surfaces the service refusal (with its code) when the condition is not a changed key', async () => {
|
||||
vi.mocked(runtime.retrust).mockRejectedValue(
|
||||
new RegistryError("this plugin's signing key has not changed — there is nothing to re-trust", 'RETRUST_NOT_APPLICABLE'),
|
||||
);
|
||||
|
||||
const { status, body } = await wireFailure(() =>
|
||||
controller.retrust('flight-tracker', { version: '2.0.0', publicKey: 'K' }, ADMIN, REQ),
|
||||
);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toMatchObject({ code: 'RETRUST_NOT_APPLICABLE' });
|
||||
});
|
||||
|
||||
it('surfaces a TOCTOU key mismatch (the entry was re-keyed since the dialog rendered)', async () => {
|
||||
vi.mocked(runtime.retrust).mockRejectedValue(new RegistryError('the signing key changed again', 'RETRUST_KEY_MISMATCH'));
|
||||
|
||||
const { body } = await wireFailure(() => controller.retrust('flight-tracker', { version: '2.0.0', publicKey: 'STALE' }, ADMIN, REQ));
|
||||
expect(body).toMatchObject({ code: 'RETRUST_KEY_MISMATCH' });
|
||||
});
|
||||
|
||||
// A plugin that isn't there is a 404, not a 400 — the request was well-formed. The code
|
||||
// still rides along, so a client that keys off it keeps working either way.
|
||||
it('answers 404 (not 400) for a plugin that does not exist', async () => {
|
||||
vi.mocked(runtime.retrust).mockRejectedValue(new RegistryError('plugin ghost not found', 'NOT_FOUND'));
|
||||
|
||||
const { status, body } = await wireFailure(() => controller.retrust('ghost', { version: '2.0.0', publicKey: 'K' }, ADMIN, REQ));
|
||||
expect(status).toBe(404);
|
||||
expect(body).toEqual({ error: 'plugin ghost not found', code: 'NOT_FOUND' });
|
||||
});
|
||||
|
||||
// The 404 is scoped to NOT_FOUND alone. Every other refusal on this surface — and every
|
||||
// install/update failure, which never carries that code — stays a 400.
|
||||
it('leaves every other refusal on 400', async () => {
|
||||
vi.mocked(runtime.retrust).mockRejectedValue(new RegistryError('nothing to re-trust', 'RETRUST_NOT_APPLICABLE'));
|
||||
expect((await wireFailure(() => controller.retrust('p', { version: '1', publicKey: 'K' }, ADMIN, REQ))).status).toBe(400);
|
||||
|
||||
vi.mocked(registry.install).mockRejectedValue(new RegistryError('plugin ghost not in registry'));
|
||||
expect((await wireFailure(() => controller.install({ id: 'ghost' }))).status).toBe(400);
|
||||
});
|
||||
|
||||
it('is refused when the plugin runtime is disabled by server configuration', async () => {
|
||||
process.env.TREK_PLUGINS_ENABLED = 'false';
|
||||
const { status } = await wireFailure(() => controller.retrust('flight-tracker', { version: '2.0.0', publicKey: 'K' }, ADMIN, REQ));
|
||||
expect(status).toBe(503);
|
||||
expect(runtime.retrust).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Re-trusting a signing key is an admin act, and there is no non-admin route to it:
|
||||
// the guards sit on the CLASS, so every route on this controller — including the one
|
||||
// added here — is behind them. Asserted on the metadata rather than on one handler, so
|
||||
// it keeps holding for routes added later.
|
||||
it('is admin-only (the guards are class-level, so retrust inherits them)', () => {
|
||||
const guards = (Reflect.getMetadata('__guards__', PluginsController) ?? []) as Array<{ name: string }>;
|
||||
expect(guards.map((g) => g.name)).toEqual(['JwtAuthGuard', 'AdminGuard']);
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,8 @@ const { testDb } = vi.hoisted(() => {
|
||||
db.exec(`CREATE TABLE plugins (
|
||||
id TEXT PRIMARY KEY, name TEXT, description TEXT, type TEXT, icon TEXT, version TEXT,
|
||||
status TEXT, enabled INTEGER DEFAULT 0, last_error TEXT, reviewed_at TEXT, source_repo TEXT, config TEXT DEFAULT '{}', permissions TEXT DEFAULT '[]', capabilities TEXT DEFAULT '{}', dependencies TEXT DEFAULT '{}', operator_egress INTEGER DEFAULT 0, updated_at TEXT,
|
||||
sort_order INTEGER DEFAULT 0);
|
||||
author_pubkey TEXT, update_block_code TEXT, update_block_detail TEXT, update_block_version TEXT,
|
||||
trek_range TEXT, sort_order INTEGER DEFAULT 0);
|
||||
CREATE TABLE plugin_settings_fields (plugin_id TEXT, field_key TEXT, scope TEXT, secret INTEGER);
|
||||
CREATE TABLE plugin_error_log (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, level TEXT, message TEXT, ts TEXT DEFAULT '2026-01-01');`);
|
||||
return { testDb: db };
|
||||
@@ -60,6 +61,65 @@ describe('PluginsService.list', () => {
|
||||
expect(out.plugins).toEqual([]);
|
||||
});
|
||||
|
||||
// The four trust states an admin can be in. `signed` derives from the TOFU-pinned
|
||||
// author key; sideloaded/dev-linked derive from source_repo — so they are NOT
|
||||
// mutually exclusive in the data, and a sideloaded plugin legitimately reports
|
||||
// signed:false. The UI's precedence rule (source badge wins) depends on that being
|
||||
// reported honestly rather than papered over here.
|
||||
describe('signature status', () => {
|
||||
const insert = (id: string, sourceRepo: string | null, pubkey: string | null) =>
|
||||
testDb
|
||||
.prepare('INSERT INTO plugins (id, name, type, status, version, source_repo, author_pubkey) VALUES (?,?,?,?,?,?,?)')
|
||||
.run(id, id, 'widget', 'inactive', '1.0.0', sourceRepo, pubkey);
|
||||
|
||||
it('reports signed + a display fingerprint for a registry plugin with a pinned key', () => {
|
||||
const key = 'RWTvBn0aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789abcd';
|
||||
insert('signed-one', 'acme/signed-one', key);
|
||||
|
||||
const p = new PluginsService().list().plugins[0];
|
||||
expect(p.signed).toBe(true);
|
||||
// Short head…tail, for eyeballing against what the author reads out over the
|
||||
// phone. NOT a confidentiality measure — the key is public, and the re-trust
|
||||
// round-trip deliberately carries it in full.
|
||||
expect(p.keyFingerprint).toBe(`${key.slice(0, 8)}…${key.slice(-8)}`);
|
||||
});
|
||||
|
||||
it('reports unsigned for a registry plugin with no pinned key', () => {
|
||||
insert('plain', 'acme/plain', null);
|
||||
const p = new PluginsService().list().plugins[0];
|
||||
expect(p.signed).toBe(false);
|
||||
expect(p.keyFingerprint).toBeNull();
|
||||
});
|
||||
|
||||
it('reports unsigned for a sideloaded and a dev-linked plugin (they carry no key)', () => {
|
||||
insert('uploaded', 'local:upload', null);
|
||||
insert('linked', 'local:link', null);
|
||||
const plugins = new PluginsService().list().plugins;
|
||||
expect(plugins.map((p) => [p.id, p.signed, p.source_repo])).toEqual([
|
||||
['linked', false, 'local:link'],
|
||||
['uploaded', false, 'local:upload'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('surfaces a recorded update block, and reports none when there is none', () => {
|
||||
insert('blocked', 'acme/blocked', 'RWTvBn0aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789abcd');
|
||||
insert('fine', 'acme/fine', null);
|
||||
testDb
|
||||
.prepare('UPDATE plugins SET update_block_code = ?, update_block_detail = ?, update_block_version = ? WHERE id = ?')
|
||||
.run('SIGNATURE_KEY_CHANGED', 'the key changed', '2.0.0', 'blocked');
|
||||
|
||||
const byId = Object.fromEntries(new PluginsService().list().plugins.map((p) => [p.id, p]));
|
||||
expect(byId.blocked.updateBlock).toEqual({ code: 'SIGNATURE_KEY_CHANGED', detail: 'the key changed', version: '2.0.0' });
|
||||
expect(byId.fine.updateBlock).toBeNull();
|
||||
});
|
||||
|
||||
it('never leaks the raw pinned key into the list response (only the fingerprint)', () => {
|
||||
insert('signed-one', 'acme/signed-one', 'RWTvBn0aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789abcd');
|
||||
const p = new PluginsService().list().plugins[0] as Record<string, unknown>;
|
||||
expect(p.author_pubkey).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('controller delegates to the service', () => {
|
||||
const svc = { list: vi.fn(() => ({ enabled: false, plugins: [] })) } as unknown as PluginsService;
|
||||
const runtime = {} as unknown as import('../../../src/nest/plugins/plugin-runtime.service').PluginRuntimeService;
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
* child (the router runs in the host).
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import Database from 'better-sqlite3';
|
||||
import { createTables } from '../../../src/db/schema';
|
||||
import { PluginRpcHost, type HostDeps } from '../../../src/nest/plugins/host/rpc-host';
|
||||
import type { RpcRequest, RpcResponse, RpcError } from '../../../src/nest/plugins/protocol/envelope';
|
||||
|
||||
@@ -1030,6 +1032,33 @@ describe('PluginRpcHost — capability enforcement', () => {
|
||||
expect((noUser as RpcError).error.code).toBe('RESOURCE_FORBIDDEN');
|
||||
});
|
||||
|
||||
// Regression (#getPlaces): the mocked `db.prepare` above never parses the SQL, so an
|
||||
// invalid column sails through. Drive the trip reads against a REAL schema-loaded
|
||||
// SQLite instead — `places` has no day_id/position (those live on day_assignments),
|
||||
// so the old `ORDER BY day_id, position` threw `no such column` on every call.
|
||||
it('trips.getPlaces runs against the real places schema and returns the trip pool', async () => {
|
||||
const realDb = new Database(':memory:');
|
||||
createTables(realDb);
|
||||
realDb.exec(`
|
||||
INSERT INTO users (id, username, email, password_hash) VALUES (42, 'ada', 'ada@example.com', 'x');
|
||||
INSERT INTO trips (id, user_id, title) VALUES (1, 42, 'Japan'), (2, 42, 'Peru');
|
||||
INSERT INTO places (id, trip_id, name, created_at) VALUES
|
||||
(1, 1, 'Older', '2027-01-01 10:00:00'),
|
||||
(2, 1, 'Newer', '2027-01-02 10:00:00'),
|
||||
(3, 2, 'Other trip', '2027-01-03 10:00:00');
|
||||
`);
|
||||
deps.db = realDb as unknown as HostDeps['db'];
|
||||
|
||||
const host = new PluginRpcHost('p', new Set(['db:read:trips']), deps);
|
||||
const res = await host.dispatch(req('trips.getPlaces', { tripId: 1 }), 42);
|
||||
|
||||
expect(ok(res)).toBe(true);
|
||||
const rows = (res as RpcResponse).result as Array<{ id: number; name: string }>;
|
||||
// only trip 1's places, newest first (mirrors the REST list's created_at DESC)
|
||||
expect(rows.map(r => r.name)).toEqual(['Newer', 'Older']);
|
||||
realDb.close();
|
||||
});
|
||||
|
||||
it('meta WRITES need the entity edit permission — a read-only member is RESOURCE_FORBIDDEN', async () => {
|
||||
// Member can access the trip but not edit it (viewer role).
|
||||
(deps.canEditTrip as ReturnType<typeof vi.fn>).mockReturnValue(false);
|
||||
|
||||
@@ -205,14 +205,17 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.viewErrors': 'عرض سجل الأخطاء',
|
||||
'admin.plugins.errorLog': 'سجل الأخطاء',
|
||||
'admin.plugins.allowedHosts': 'المضيفون المسموح بهم',
|
||||
'admin.plugins.allowedHosts.hint': 'تتصل هذه الإضافة بخدمة لا يمكن لأحد سواك تسميتها (خادم مستضاف ذاتيًا). أضف المضيفين الذين يمكنها الوصول إليهم — ولن تصل إلى غيرهم.',
|
||||
'admin.plugins.allowedHosts.hint':
|
||||
'تتصل هذه الإضافة بخدمة لا يمكن لأحد سواك تسميتها (خادم مستضاف ذاتيًا). أضف المضيفين الذين يمكنها الوصول إليهم — ولن تصل إلى غيرهم.',
|
||||
'admin.plugins.allowedHosts.none': 'لم تتم إضافة أي مضيف بعد.',
|
||||
'admin.plugins.allowedHosts.unsupported': 'لا تستخدم هذه الإضافة مضيفين يحددهم المشغّل. مضيفوها المسموح بهم ثابتون في ملف التعريف.',
|
||||
'admin.plugins.allowedHosts.unsupported':
|
||||
'لا تستخدم هذه الإضافة مضيفين يحددهم المشغّل. مضيفوها المسموح بهم ثابتون في ملف التعريف.',
|
||||
'admin.plugins.allowedHosts.restartNote': 'الحفظ يعيد تشغيل الإضافة لتأخذ القائمة الجديدة.',
|
||||
'admin.plugins.allowedHosts.add': 'إضافة مضيف مسموح به',
|
||||
'admin.plugins.allowedHosts.count': '{n} مضيف مسموح به',
|
||||
'admin.plugins.operatorEgressPill': '+ مضيفون تضيفهم',
|
||||
'admin.plugins.operatorEgressHint': 'تتصل هذه الإضافة بخدمة لا يمكن لأحد سواك تسميتها (خادم مستضاف ذاتيًا). بعد التثبيت أضف المضيفين من ⋯ ← المضيفون المسموح بهم. ولن تصل إلى غيرهم.',
|
||||
'admin.plugins.operatorEgressHint':
|
||||
'تتصل هذه الإضافة بخدمة لا يمكن لأحد سواك تسميتها (خادم مستضاف ذاتيًا). بعد التثبيت أضف المضيفين من ⋯ ← المضيفون المسموح بهم. ولن تصل إلى غيرهم.',
|
||||
'admin.plugins.noErrors': 'لا توجد أخطاء مسجّلة.',
|
||||
'admin.plugins.uninstalled': 'تمت إزالة الإضافة',
|
||||
'admin.plugins.uninstallTitle': 'إزالة الإضافة؟',
|
||||
@@ -364,6 +367,34 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.security.reviewedTitle': 'ماذا تعني "تمت مراجعتها"',
|
||||
'admin.plugins.security.reviewedBody':
|
||||
'الإضافة التي تمت مراجعتها فُحصت يدويًا بحثًا عن البرمجيات الخبيثة بواسطة أحد القائمين على TREK في كل إصدار — جرى فحصها بحثًا عن شيفرة ضارة، لا لمعرفة ما إذا كانت تعمل جيدًا. وهي ليست وعدًا بأن الإضافة غير ضارة.',
|
||||
'admin.plugins.security.signedTitle': 'ماذا تعني "موقّعة"',
|
||||
'admin.plugins.security.signedBody':
|
||||
'المجموع الاختباري الذي يتحقق منه TREK عند كل تثبيت يثبت أن الملفات هي بالضبط ما يضمنه السجل. أما التوقيع فيثبت شيئًا مختلفًا: أنها جاءت من المؤلف، موقّعة بمفتاح لا يملكه سواه. الإضافة الموقّعة تجمع الاثنين معًا. وغير الموقّعة ليست غير آمنة — إنها ببساطة تحمل ضمانًا واحدًا أقل، ومعظم الإضافات في السجل غير موقّعة اليوم.',
|
||||
'admin.plugins.signed': 'موقّعة',
|
||||
'admin.plugins.signedHint': 'جرى التحقق منها مقابل مفتاح توقيع المؤلف عند تثبيتها',
|
||||
'admin.plugins.unsigned': 'غير موقّعة',
|
||||
'admin.plugins.unsignedHint':
|
||||
'الملفات تطابق ما يضمنه السجل، لكن لا شيء يربطها بالمؤلف. ضمان واحد أقل — وليست غير آمنة.',
|
||||
'admin.plugins.updateBlocked': 'تم حظر التحديث — {reason}',
|
||||
'admin.plugins.reviewBlock': 'مراجعة',
|
||||
'admin.plugins.retrusted': 'تم الوثوق بمفتاح التوقيع الجديد — وتم تحديث الإضافة',
|
||||
'admin.plugins.sig.title': 'تعذّر التحقق من توقيع {name}',
|
||||
'admin.plugins.sig.keyChangedBody':
|
||||
'مفتاح توقيع المؤلف ليس المفتاح الذي ثُبّتت به هذه الإضافة. المؤلفون يبدّلون مفاتيحهم أحيانًا — لكن مهاجمًا استولى على الإضافة سيبدو تمامًا هكذا.',
|
||||
'admin.plugins.sig.invalidBody':
|
||||
'الملفات لا تطابق توقيع المؤلف. إنها ليست ما وقّعه المؤلف — فإما أنها تلفت، وإما أنه جرى العبث بها. لا يمكن تجاوز ذلك.',
|
||||
'admin.plugins.sig.missingBody':
|
||||
'كانت هذه الإضافة موقّعة عند تثبيتها، لكن الإصدار الجديد لا يحمل أي توقيع. لن يقبل TREK هذا التراجع بصمت. لا يمكن تجاوز ذلك.',
|
||||
'admin.plugins.sig.incompleteBody':
|
||||
'مدخل السجل موقّع نصف توقيع: يعلن عن مفتاح للمؤلف لكن الإصدار لا يحمل توقيعًا (أو العكس). هذا خطأ من جانب الإضافة. لا يمكن تجاوز ذلك.',
|
||||
'admin.plugins.sig.pinnedKey': 'المفتاح الذي ثُبّتت به',
|
||||
'admin.plugins.sig.newKey': 'المفتاح الذي تقدّمه الآن',
|
||||
'admin.plugins.sig.confirmOutOfBand':
|
||||
'لا يستطيع TREK التمييز بين تبديل مشروع للمفتاح وبين استيلاء على الإضافة — يبدو الأمران متطابقين من هنا. تحقّق من المفتاح الجديد مع المؤلف عبر قناة تثق بها أصلًا قبل قبوله. وحين تفعل، يتم تحديث الإضافة ويُحفظ المفتاح الجديد.',
|
||||
'admin.plugins.sig.retrustConfirm': 'الوثوق بالمفتاح الجديد والتحديث',
|
||||
'admin.plugins.sig.cancel': 'عدم الوثوق به',
|
||||
'admin.plugins.sig.consentUnsigned':
|
||||
'لا شيء يربط هذا الإصدار بمؤلفه — الملفات تطابق السجل، لكنها لا تحمل توقيع المؤلف.',
|
||||
'admin.plugins.security.trustTitle': 'الخلاصة',
|
||||
'admin.plugins.security.trustBody':
|
||||
'تثبيت إضافة يشبه تثبيت أي تطبيق من جهة خارجية: لا تُضِف سوى شيفرة من مطورين تثق بهم، وعند الشك، افحصها بنفسك أولًا. لا يتحمل TREK أي مسؤولية عن إضافات الجهات الخارجية.',
|
||||
@@ -423,6 +454,10 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.dep.download': 'تنزيل',
|
||||
'admin.plugins.dep.update': 'تحديث',
|
||||
'admin.plugins.dep.resolveHint': 'ينزّل أحدث إصدار متوافق، بما في ذلك تبعياته الخاصة.',
|
||||
'admin.plugins.dep.trekIncompatible': 'يتطلب TREK {range} — يعمل هذا الخادم بالإصدار {host}',
|
||||
'admin.plugins.dep.trekUnknown': 'لا يحدد إصدارات TREK المدعومة',
|
||||
'admin.plugins.installCompatible': 'تثبيت {version}',
|
||||
'admin.plugins.incompatible': 'غير متوافق',
|
||||
'admin.plugins.accessTitle': 'ما الذي يمكنها الوصول إليه',
|
||||
'admin.plugins.connectsTitle': 'يتصل بـ',
|
||||
'admin.plugins.detailsTitle': 'التفاصيل',
|
||||
|
||||
@@ -185,14 +185,17 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.viewErrors': 'Ver registro de erros',
|
||||
'admin.plugins.errorLog': 'Registro de erros',
|
||||
'admin.plugins.allowedHosts': 'Hosts permitidos',
|
||||
'admin.plugins.allowedHosts.hint': 'Este plugin fala com um serviço que só você pode nomear (um servidor auto-hospedado). Adicione os hosts que ele pode alcançar — nenhum outro.',
|
||||
'admin.plugins.allowedHosts.hint':
|
||||
'Este plugin fala com um serviço que só você pode nomear (um servidor auto-hospedado). Adicione os hosts que ele pode alcançar — nenhum outro.',
|
||||
'admin.plugins.allowedHosts.none': 'Nenhum host adicionado ainda.',
|
||||
'admin.plugins.allowedHosts.unsupported': 'Este plugin não usa hosts fornecidos pelo operador. Seus hosts permitidos são fixos no manifesto.',
|
||||
'admin.plugins.allowedHosts.unsupported':
|
||||
'Este plugin não usa hosts fornecidos pelo operador. Seus hosts permitidos são fixos no manifesto.',
|
||||
'admin.plugins.allowedHosts.restartNote': 'Salvar reinicia o plugin para que ele use a nova lista.',
|
||||
'admin.plugins.allowedHosts.add': 'Adicionar host permitido',
|
||||
'admin.plugins.allowedHosts.count': '{n} host(s) permitido(s)',
|
||||
'admin.plugins.operatorEgressPill': '+ hosts que você adicionar',
|
||||
'admin.plugins.operatorEgressHint': 'Este plugin fala com um serviço que só você pode nomear (um servidor auto-hospedado). Após instalar, adicione os hosts que ele pode alcançar em ⋯ → Hosts permitidos. Nenhum outro.',
|
||||
'admin.plugins.operatorEgressHint':
|
||||
'Este plugin fala com um serviço que só você pode nomear (um servidor auto-hospedado). Após instalar, adicione os hosts que ele pode alcançar em ⋯ → Hosts permitidos. Nenhum outro.',
|
||||
'admin.plugins.noErrors': 'Nenhum erro registrado.',
|
||||
'admin.plugins.uninstalled': 'Plugin desinstalado',
|
||||
'admin.plugins.uninstallTitle': 'Desinstalar plugin?',
|
||||
@@ -352,6 +355,34 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.security.reviewedTitle': 'O que significa "Revisado"',
|
||||
'admin.plugins.security.reviewedBody':
|
||||
'Um plugin revisado foi verificado manualmente em busca de malware por um mantenedor do TREK em cada versão — checado quanto a código malicioso, não quanto a funcionar bem. Não é uma promessa de que o plugin seja inofensivo.',
|
||||
'admin.plugins.security.signedTitle': 'O que significa "Assinado"',
|
||||
'admin.plugins.security.signedBody':
|
||||
'A soma de verificação que o TREK confere em toda instalação prova que os arquivos são exatamente aqueles pelos quais o registro responde. Uma assinatura prova algo diferente: que eles vieram do autor, assinados com uma chave que só ele possui. Um plugin assinado tem as duas coisas. Um plugin sem assinatura não é inseguro — ele apenas carrega uma garantia a menos, e hoje a maioria dos plugins do registro não é assinada.',
|
||||
'admin.plugins.signed': 'Assinado',
|
||||
'admin.plugins.signedHint': 'Verificado contra a chave de assinatura do autor no momento da instalação',
|
||||
'admin.plugins.unsigned': 'Sem assinatura',
|
||||
'admin.plugins.unsignedHint':
|
||||
'Os arquivos correspondem àquilo pelo qual o registro responde, mas nada os liga ao autor. Uma garantia a menos — não é inseguro.',
|
||||
'admin.plugins.updateBlocked': 'Atualização bloqueada — {reason}',
|
||||
'admin.plugins.reviewBlock': 'Revisar',
|
||||
'admin.plugins.retrusted': 'Nova chave de assinatura confiada — o plugin foi atualizado',
|
||||
'admin.plugins.sig.title': 'Não foi possível verificar a assinatura de {name}',
|
||||
'admin.plugins.sig.keyChangedBody':
|
||||
'A chave de assinatura do autor não é aquela com a qual este plugin foi instalado. Autores realmente trocam de chave — mas um invasor que tivesse assumido o plugin se pareceria exatamente com isto.',
|
||||
'admin.plugins.sig.invalidBody':
|
||||
'Os arquivos não correspondem à assinatura do autor. Eles não são o que o autor assinou — ou foram corrompidos, ou foram adulterados. Isso não pode ser ignorado.',
|
||||
'admin.plugins.sig.missingBody':
|
||||
'Este plugin estava assinado quando você o instalou, mas a nova versão não traz assinatura alguma. O TREK não vai aceitar essa perda em silêncio. Isso não pode ser ignorado.',
|
||||
'admin.plugins.sig.incompleteBody':
|
||||
'A entrada no registro está assinada pela metade: ela declara uma chave do autor, mas a versão não traz assinatura (ou o contrário). Isso é um erro do lado do plugin. Isso não pode ser ignorado.',
|
||||
'admin.plugins.sig.pinnedKey': 'Chave com que foi instalado',
|
||||
'admin.plugins.sig.newKey': 'Chave que ele está oferecendo agora',
|
||||
'admin.plugins.sig.confirmOutOfBand':
|
||||
'O TREK não consegue distinguir uma troca legítima de chave de um sequestro do plugin — daqui, as duas coisas são idênticas. Confirme a nova chave com o autor por um canal em que você já confia antes de aceitá-la. Assim que aceitar, o plugin é atualizado e a nova chave passa a ser lembrada.',
|
||||
'admin.plugins.sig.retrustConfirm': 'Confiar na nova chave e atualizar',
|
||||
'admin.plugins.sig.cancel': 'Não confiar',
|
||||
'admin.plugins.sig.consentUnsigned':
|
||||
'Nada liga esta versão ao seu autor — os arquivos correspondem ao registro, mas não trazem assinatura do autor.',
|
||||
'admin.plugins.security.trustTitle': 'Em resumo',
|
||||
'admin.plugins.security.trustBody':
|
||||
'Instalar um plugin é como instalar qualquer app de terceiros: adicione apenas código de autores em quem você confia e, em caso de dúvida, inspecione-o você mesmo antes. O TREK não assume nenhuma responsabilidade por plugins de terceiros.',
|
||||
@@ -411,6 +442,10 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.dep.download': 'Baixar',
|
||||
'admin.plugins.dep.update': 'Atualizar',
|
||||
'admin.plugins.dep.resolveHint': 'Baixa a versão compatível mais recente, incluindo suas próprias dependências.',
|
||||
'admin.plugins.dep.trekIncompatible': 'Requer TREK {range} — este servidor executa {host}',
|
||||
'admin.plugins.dep.trekUnknown': 'Não informa quais versões do TREK são compatíveis',
|
||||
'admin.plugins.installCompatible': 'Instalar {version}',
|
||||
'admin.plugins.incompatible': 'Incompatível',
|
||||
'admin.plugins.accessTitle': 'O que ele pode acessar',
|
||||
'admin.plugins.connectsTitle': 'Conecta-se a',
|
||||
'admin.plugins.detailsTitle': 'Detalhes',
|
||||
|
||||
@@ -296,7 +296,8 @@ const settings: TranslationStrings = {
|
||||
'settings.notificationPreferences.configure': 'Configurar',
|
||||
'settings.notificationPreferences.testSuccess': 'Notificação de teste enviada.',
|
||||
'settings.notificationPreferences.testFailed': 'Falha no teste.',
|
||||
'settings.notificationPreferences.pluginConfigured': 'Configurado. Gerencie suas credenciais na página de configurações do plugin.',
|
||||
'settings.notificationPreferences.pluginConfigured':
|
||||
'Configurado. Gerencie suas credenciais na página de configurações do plugin.',
|
||||
'settings.notificationPreferences.webhook': 'Webhook',
|
||||
'settings.notificationPreferences.email': 'Email',
|
||||
'settings.notificationPreferences.ntfy': 'Ntfy',
|
||||
|
||||
@@ -186,14 +186,17 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.viewErrors': 'Zobrazit protokol chyb',
|
||||
'admin.plugins.errorLog': 'Protokol chyb',
|
||||
'admin.plugins.allowedHosts': 'Povolení hostitelé',
|
||||
'admin.plugins.allowedHosts.hint': 'Tento plugin komunikuje se službou, kterou umíš pojmenovat jen ty (self-hosted server). Přidej hostitele, na které smí — na jiné se nedostane.',
|
||||
'admin.plugins.allowedHosts.hint':
|
||||
'Tento plugin komunikuje se službou, kterou umíš pojmenovat jen ty (self-hosted server). Přidej hostitele, na které smí — na jiné se nedostane.',
|
||||
'admin.plugins.allowedHosts.none': 'Zatím nebyli přidáni žádní hostitelé.',
|
||||
'admin.plugins.allowedHosts.unsupported': 'Tento plugin nepoužívá hostitele zadané provozovatelem. Povolení hostitelé jsou pevně dáni v manifestu.',
|
||||
'admin.plugins.allowedHosts.unsupported':
|
||||
'Tento plugin nepoužívá hostitele zadané provozovatelem. Povolení hostitelé jsou pevně dáni v manifestu.',
|
||||
'admin.plugins.allowedHosts.restartNote': 'Uložení restartuje plugin, aby načetl nový seznam.',
|
||||
'admin.plugins.allowedHosts.add': 'Přidat povoleného hostitele',
|
||||
'admin.plugins.allowedHosts.count': '{n} povolených hostitelů',
|
||||
'admin.plugins.operatorEgressPill': '+ hostitelé, které přidáš',
|
||||
'admin.plugins.operatorEgressHint': 'Tento plugin komunikuje se službou, kterou umíš pojmenovat jen ty (self-hosted server). Po instalaci přidej hostitele v ⋯ → Povolení hostitelé. Na jiné se nedostane.',
|
||||
'admin.plugins.operatorEgressHint':
|
||||
'Tento plugin komunikuje se službou, kterou umíš pojmenovat jen ty (self-hosted server). Po instalaci přidej hostitele v ⋯ → Povolení hostitelé. Na jiné se nedostane.',
|
||||
'admin.plugins.noErrors': 'Žádné zaznamenané chyby.',
|
||||
'admin.plugins.uninstalled': 'Plugin odinstalován',
|
||||
'admin.plugins.uninstallTitle': 'Odinstalovat plugin?',
|
||||
@@ -350,6 +353,34 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.security.reviewedTitle': 'Co znamená „Zkontrolováno“',
|
||||
'admin.plugins.security.reviewedBody':
|
||||
'Zkontrolovaný plugin ručně prověřil správce TREK na škodlivý kód u každé verze — kontroloval nepřátelský kód, nikoli to, zda dobře funguje. Není to slib, že je plugin neškodný.',
|
||||
'admin.plugins.security.signedTitle': 'Co znamená „Podepsáno“',
|
||||
'admin.plugins.security.signedBody':
|
||||
'Kontrolní součet, který TREK ověřuje při každé instalaci, dokazuje, že soubory jsou přesně tím, za co se registr zaručuje. Podpis dokazuje něco jiného: že pocházejí od autora a jsou podepsané klíčem, který drží jen on. Podepsaný plugin má obojí. Nepodepsaný není nebezpečný — nese jen o jednu záruku méně a většina pluginů v registru dnes podepsaná není.',
|
||||
'admin.plugins.signed': 'Podepsáno',
|
||||
'admin.plugins.signedHint': 'Při instalaci ověřeno proti podpisovému klíči autora',
|
||||
'admin.plugins.unsigned': 'Nepodepsáno',
|
||||
'admin.plugins.unsignedHint':
|
||||
'Soubory odpovídají tomu, za co se registr zaručuje, ale nic je nespojuje s autorem. O jednu záruku méně — nikoli nebezpečné.',
|
||||
'admin.plugins.updateBlocked': 'Aktualizace zablokována — {reason}',
|
||||
'admin.plugins.reviewBlock': 'Zkontrolovat',
|
||||
'admin.plugins.retrusted': 'Nový podpisový klíč je důvěryhodný — plugin je aktualizován',
|
||||
'admin.plugins.sig.title': 'Podpis pluginu {name} se nepodařilo ověřit',
|
||||
'admin.plugins.sig.keyChangedBody':
|
||||
'Podpisový klíč autora není ten, se kterým byl tento plugin nainstalován. Autoři klíče skutečně mění — ale útočník, který plugin převzal, by vypadal přesně takhle.',
|
||||
'admin.plugins.sig.invalidBody':
|
||||
'Soubory neodpovídají podpisu autora. Nejsou tím, co autor podepsal — buď se poškodily, nebo do nich někdo zasáhl. Toto nelze obejít.',
|
||||
'admin.plugins.sig.missingBody':
|
||||
'Tento plugin byl při instalaci podepsaný, ale nová verze žádný podpis nepřináší. TREK takové zhoršení tiše nepřijme. Toto nelze obejít.',
|
||||
'admin.plugins.sig.incompleteBody':
|
||||
'Záznam v registru je podepsaný jen napůl: uvádí klíč autora, ale verze nenese žádný podpis (nebo naopak). To je chyba na straně pluginu. Toto nelze obejít.',
|
||||
'admin.plugins.sig.pinnedKey': 'Klíč, se kterým byl nainstalován',
|
||||
'admin.plugins.sig.newKey': 'Klíč, který nabízí nyní',
|
||||
'admin.plugins.sig.confirmOutOfBand':
|
||||
'TREK nedokáže odlišit legitimní výměnu klíče od převzetí pluginu — odsud vypadají obě stejně. Než nový klíč přijmete, ověřte si ho u autora kanálem, kterému už důvěřujete. Jakmile to uděláte, plugin se aktualizuje a nový klíč si TREK zapamatuje.',
|
||||
'admin.plugins.sig.retrustConfirm': 'Důvěřovat novému klíči a aktualizovat',
|
||||
'admin.plugins.sig.cancel': 'Nedůvěřovat',
|
||||
'admin.plugins.sig.consentUnsigned':
|
||||
'Tuto verzi nic nespojuje s jejím autorem — soubory odpovídají registru, ale nenesou podpis autora.',
|
||||
'admin.plugins.security.trustTitle': 'Závěrem',
|
||||
'admin.plugins.security.trustBody':
|
||||
'Instalace pluginu je jako instalace jakékoli aplikace třetí strany: přidávejte pouze kód od autorů, kterým důvěřujete, a v případě pochybností si jej nejprve sami prohlédněte. TREK nenese žádnou odpovědnost za pluginy třetích stran.',
|
||||
@@ -409,6 +440,10 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.dep.download': 'Stáhnout',
|
||||
'admin.plugins.dep.update': 'Aktualizovat',
|
||||
'admin.plugins.dep.resolveHint': 'Stáhne nejnovější kompatibilní verzi včetně jejích vlastních závislostí.',
|
||||
'admin.plugins.dep.trekIncompatible': 'Vyžaduje TREK {range} — tento server běží na {host}',
|
||||
'admin.plugins.dep.trekUnknown': 'Neuvádí, které verze TREK podporuje',
|
||||
'admin.plugins.installCompatible': 'Nainstalovat {version}',
|
||||
'admin.plugins.incompatible': 'Nekompatibilní',
|
||||
'admin.plugins.accessTitle': 'K čemu má přístup',
|
||||
'admin.plugins.connectsTitle': 'Připojuje se k',
|
||||
'admin.plugins.detailsTitle': 'Podrobnosti',
|
||||
|
||||
@@ -188,14 +188,18 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.viewErrors': 'Fehlerprotokoll ansehen',
|
||||
'admin.plugins.errorLog': 'Fehlerprotokoll',
|
||||
'admin.plugins.allowedHosts': 'Erlaubte Hosts',
|
||||
'admin.plugins.allowedHosts.hint': 'Dieses Plugin spricht mit einem Dienst, den nur du benennen kannst (ein selbst gehosteter Server). Füge die Hosts hinzu, die es erreichen darf — andere erreicht es nicht.',
|
||||
'admin.plugins.allowedHosts.hint':
|
||||
'Dieses Plugin spricht mit einem Dienst, den nur du benennen kannst (ein selbst gehosteter Server). Füge die Hosts hinzu, die es erreichen darf — andere erreicht es nicht.',
|
||||
'admin.plugins.allowedHosts.none': 'Noch keine Hosts hinzugefügt.',
|
||||
'admin.plugins.allowedHosts.unsupported': 'Dieses Plugin nutzt keine vom Betreiber angegebenen Hosts. Seine erlaubten Hosts stehen fest im Manifest.',
|
||||
'admin.plugins.allowedHosts.restartNote': 'Beim Speichern wird das Plugin neu gestartet, damit es die neue Liste übernimmt.',
|
||||
'admin.plugins.allowedHosts.unsupported':
|
||||
'Dieses Plugin nutzt keine vom Betreiber angegebenen Hosts. Seine erlaubten Hosts stehen fest im Manifest.',
|
||||
'admin.plugins.allowedHosts.restartNote':
|
||||
'Beim Speichern wird das Plugin neu gestartet, damit es die neue Liste übernimmt.',
|
||||
'admin.plugins.allowedHosts.add': 'Erlaubten Host hinzufügen',
|
||||
'admin.plugins.allowedHosts.count': '{n} erlaubte Hosts',
|
||||
'admin.plugins.operatorEgressPill': '+ von dir ergänzte Hosts',
|
||||
'admin.plugins.operatorEgressHint': 'Dieses Plugin spricht mit einem Dienst, den nur du benennen kannst (ein selbst gehosteter Server). Füge nach der Installation unter ⋯ → Erlaubte Hosts die erreichbaren Hosts hinzu. Andere erreicht es nicht.',
|
||||
'admin.plugins.operatorEgressHint':
|
||||
'Dieses Plugin spricht mit einem Dienst, den nur du benennen kannst (ein selbst gehosteter Server). Füge nach der Installation unter ⋯ → Erlaubte Hosts die erreichbaren Hosts hinzu. Andere erreicht es nicht.',
|
||||
'admin.plugins.noErrors': 'Keine Fehler protokolliert.',
|
||||
'admin.plugins.uninstalled': 'Plugin deinstalliert',
|
||||
'admin.plugins.uninstallTitle': 'Plugin deinstallieren?',
|
||||
@@ -359,6 +363,34 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.security.reviewedTitle': 'Was „Geprüft" bedeutet',
|
||||
'admin.plugins.security.reviewedBody':
|
||||
'Ein geprüftes Plugin wurde bei jeder Version von einem TREK-Maintainer manuell auf Schadsoftware gescannt — geprüft auf feindlichen Code, nicht darauf, ob es gut funktioniert. Es ist kein Versprechen, dass das Plugin harmlos ist.',
|
||||
'admin.plugins.security.signedTitle': 'Was „Signiert" bedeutet',
|
||||
'admin.plugins.security.signedBody':
|
||||
'Die Prüfsumme, die TREK bei jeder Installation prüft, belegt, dass die Dateien genau das sind, wofür die Registry einsteht. Eine Signatur belegt etwas anderes: dass sie vom Autor stammen, signiert mit einem Schlüssel, den nur er besitzt. Ein signiertes Plugin hat beides. Ein unsigniertes ist nicht unsicher — es trägt schlicht eine Garantie weniger, und die meisten Plugins in der Registry sind heute unsigniert.',
|
||||
'admin.plugins.signed': 'Signiert',
|
||||
'admin.plugins.signedHint': 'Bei der Installation gegen den Signaturschlüssel des Autors geprüft',
|
||||
'admin.plugins.unsigned': 'Unsigniert',
|
||||
'admin.plugins.unsignedHint':
|
||||
'Die Dateien entsprechen dem, wofür die Registry einsteht, aber nichts verbindet sie mit dem Autor. Eine Garantie weniger — nicht unsicher.',
|
||||
'admin.plugins.updateBlocked': 'Update blockiert — {reason}',
|
||||
'admin.plugins.reviewBlock': 'Ansehen',
|
||||
'admin.plugins.retrusted': 'Neuem Signaturschlüssel vertraut — das Plugin ist aktualisiert',
|
||||
'admin.plugins.sig.title': 'Die Signatur von {name} konnte nicht geprüft werden',
|
||||
'admin.plugins.sig.keyChangedBody':
|
||||
'Der Signaturschlüssel des Autors ist nicht der, mit dem dieses Plugin installiert wurde. Autoren wechseln ihre Schlüssel durchaus — aber ein Angreifer, der das Plugin übernommen hat, sähe von hier aus genau so aus.',
|
||||
'admin.plugins.sig.invalidBody':
|
||||
'Die Dateien passen nicht zur Signatur des Autors. Sie sind nicht das, was der Autor signiert hat — entweder wurden sie beschädigt oder manipuliert. Das lässt sich nicht übergehen.',
|
||||
'admin.plugins.sig.missingBody':
|
||||
'Dieses Plugin war signiert, als du es installiert hast, aber die neue Version bringt keine Signatur mit. TREK akzeptiert diesen Rückschritt nicht stillschweigend. Das lässt sich nicht übergehen.',
|
||||
'admin.plugins.sig.incompleteBody':
|
||||
'Der Registry-Eintrag ist nur halb signiert: Er nennt einen Autorenschlüssel, die Version trägt aber keine Signatur (oder umgekehrt). Das ist ein Fehler auf Seiten des Plugins. Das lässt sich nicht übergehen.',
|
||||
'admin.plugins.sig.pinnedKey': 'Schlüssel, mit dem es installiert wurde',
|
||||
'admin.plugins.sig.newKey': 'Schlüssel, den es jetzt anbietet',
|
||||
'admin.plugins.sig.confirmOutOfBand':
|
||||
'TREK kann einen legitimen Schlüsselwechsel nicht von einer Übernahme unterscheiden — von hier aus sehen beide gleich aus. Bestätige den neuen Schlüssel beim Autor über einen Kanal, dem du bereits vertraust, bevor du ihn annimmst. Sobald du das tust, wird das Plugin aktualisiert und der neue Schlüssel gemerkt.',
|
||||
'admin.plugins.sig.retrustConfirm': 'Neuem Schlüssel vertrauen & aktualisieren',
|
||||
'admin.plugins.sig.cancel': 'Nicht vertrauen',
|
||||
'admin.plugins.sig.consentUnsigned':
|
||||
'Nichts verbindet diese Version mit ihrem Autor — die Dateien entsprechen der Registry, tragen aber keine Autorensignatur.',
|
||||
'admin.plugins.security.trustTitle': 'Das Fazit',
|
||||
'admin.plugins.security.trustBody':
|
||||
'Ein Plugin zu installieren ist wie jede fremde App zu installieren: Füge nur Code von Autoren hinzu, denen du vertraust, und prüfe ihn im Zweifel zuerst selbst. TREK übernimmt keine Verantwortung für Plugins von Drittanbietern.',
|
||||
@@ -418,6 +450,10 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.dep.download': 'Herunterladen',
|
||||
'admin.plugins.dep.update': 'Aktualisieren',
|
||||
'admin.plugins.dep.resolveHint': 'Lädt die neueste kompatible Version samt eigener Abhängigkeiten herunter.',
|
||||
'admin.plugins.dep.trekIncompatible': 'Benötigt TREK {range} — auf diesem Server läuft {host}',
|
||||
'admin.plugins.dep.trekUnknown': 'Gibt nicht an, welche TREK-Versionen unterstützt werden',
|
||||
'admin.plugins.installCompatible': '{version} installieren',
|
||||
'admin.plugins.incompatible': 'Nicht kompatibel',
|
||||
'admin.plugins.accessTitle': 'Worauf es zugreift',
|
||||
'admin.plugins.connectsTitle': 'Verbindet sich mit',
|
||||
'admin.plugins.detailsTitle': 'Details',
|
||||
|
||||
@@ -298,7 +298,8 @@ const settings: TranslationStrings = {
|
||||
'settings.notificationPreferences.configure': 'Einrichten',
|
||||
'settings.notificationPreferences.testSuccess': 'Testbenachrichtigung gesendet.',
|
||||
'settings.notificationPreferences.testFailed': 'Test fehlgeschlagen.',
|
||||
'settings.notificationPreferences.pluginConfigured': 'Eingerichtet. Zugangsdaten auf der Einstellungsseite des Plugins verwalten.',
|
||||
'settings.notificationPreferences.pluginConfigured':
|
||||
'Eingerichtet. Zugangsdaten auf der Einstellungsseite des Plugins verwalten.',
|
||||
'settings.notificationPreferences.webhook': 'Webhook',
|
||||
'settings.notificationPreferences.email': 'Email',
|
||||
'settings.notificationPreferences.ntfy': 'Ntfy',
|
||||
|
||||
@@ -239,14 +239,17 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.viewErrors': 'View error log',
|
||||
'admin.plugins.errorLog': 'Error log',
|
||||
'admin.plugins.allowedHosts': 'Allowed hosts',
|
||||
'admin.plugins.allowedHosts.hint': 'This plugin talks to a service only you can name (a self-hosted server). Add the hosts it may reach — it can reach no others.',
|
||||
'admin.plugins.allowedHosts.hint':
|
||||
'This plugin talks to a service only you can name (a self-hosted server). Add the hosts it may reach — it can reach no others.',
|
||||
'admin.plugins.allowedHosts.none': 'No hosts added yet.',
|
||||
'admin.plugins.allowedHosts.unsupported': 'This plugin does not use operator-supplied hosts. Its allowed hosts are fixed in its manifest.',
|
||||
'admin.plugins.allowedHosts.unsupported':
|
||||
'This plugin does not use operator-supplied hosts. Its allowed hosts are fixed in its manifest.',
|
||||
'admin.plugins.allowedHosts.restartNote': 'Saving restarts the plugin so it picks up the new list.',
|
||||
'admin.plugins.allowedHosts.add': 'Add allowed host',
|
||||
'admin.plugins.allowedHosts.count': '{n} allowed host(s)',
|
||||
'admin.plugins.operatorEgressPill': '+ hosts you add',
|
||||
'admin.plugins.operatorEgressHint': 'This plugin talks to a service only you can name (a self-hosted server). After installing, add the hosts it may reach under ⋯ → Allowed hosts. It can reach no others.',
|
||||
'admin.plugins.operatorEgressHint':
|
||||
'This plugin talks to a service only you can name (a self-hosted server). After installing, add the hosts it may reach under ⋯ → Allowed hosts. It can reach no others.',
|
||||
'admin.plugins.noErrors': 'No errors logged.',
|
||||
'admin.plugins.uninstalled': 'Plugin uninstalled',
|
||||
'admin.plugins.uninstallTitle': 'Uninstall plugin?',
|
||||
@@ -394,6 +397,34 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.security.reviewedTitle': 'What "Reviewed" means',
|
||||
'admin.plugins.security.reviewedBody':
|
||||
'A reviewed plugin was manually scanned for malware by a TREK maintainer on each version — checked for hostile code, not for whether it works well. It is not a promise that the plugin is harmless.',
|
||||
'admin.plugins.security.signedTitle': 'What "Signed" means',
|
||||
'admin.plugins.security.signedBody':
|
||||
'The checksum TREK verifies on every install proves the files are exactly what the registry vouches for. A signature proves something different: that they came from the author, signed with a key only they hold. A signed plugin has both. An unsigned one is not unsafe — it simply carries one guarantee fewer, and most plugins in the registry are unsigned today.',
|
||||
'admin.plugins.signed': 'Signed',
|
||||
'admin.plugins.signedHint': "Verified against the author's signing key when it was installed",
|
||||
'admin.plugins.unsigned': 'Unsigned',
|
||||
'admin.plugins.unsignedHint':
|
||||
'The files match what the registry vouches for, but nothing ties them to the author. One guarantee fewer — not unsafe.',
|
||||
'admin.plugins.updateBlocked': 'Update blocked — {reason}',
|
||||
'admin.plugins.reviewBlock': 'Review',
|
||||
'admin.plugins.retrusted': 'New signing key trusted — the plugin is updated',
|
||||
'admin.plugins.sig.title': "{name}'s signature could not be verified",
|
||||
'admin.plugins.sig.keyChangedBody':
|
||||
"The author's signing key is not the one this plugin was installed with. Authors do rotate keys — but an attacker who took over the plugin would look exactly like this.",
|
||||
'admin.plugins.sig.invalidBody':
|
||||
"The files do not match the author's signature. They are not what the author signed — either they were corrupted, or they were tampered with. This cannot be overridden.",
|
||||
'admin.plugins.sig.missingBody':
|
||||
'This plugin was signed when you installed it, but the new version ships no signature. TREK will not quietly accept the downgrade. This cannot be overridden.',
|
||||
'admin.plugins.sig.incompleteBody':
|
||||
'The registry entry is half-signed: it declares an author key but the version carries no signature (or the reverse). That is a mistake on the plugin side. This cannot be overridden.',
|
||||
'admin.plugins.sig.pinnedKey': 'Key it was installed with',
|
||||
'admin.plugins.sig.newKey': 'Key it is offering now',
|
||||
'admin.plugins.sig.confirmOutOfBand':
|
||||
'TREK cannot tell a legitimate key rotation apart from a takeover — both look identical from here. Confirm the new key with the author through a channel you already trust before you accept it. Once you do, the plugin is updated and the new key is remembered.',
|
||||
'admin.plugins.sig.retrustConfirm': 'Trust the new key & update',
|
||||
'admin.plugins.sig.cancel': 'Do not trust it',
|
||||
'admin.plugins.sig.consentUnsigned':
|
||||
'Nothing ties this version to its author — the files match the registry, but they carry no author signature.',
|
||||
'admin.plugins.security.trustTitle': 'The bottom line',
|
||||
'admin.plugins.security.trustBody':
|
||||
'Installing a plugin is like installing any third-party app: only add code from authors you trust, and when in doubt, inspect it yourself first. TREK takes no responsibility for third-party plugins.',
|
||||
@@ -453,6 +484,10 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.dep.download': 'Download',
|
||||
'admin.plugins.dep.update': 'Update',
|
||||
'admin.plugins.dep.resolveHint': 'Downloads the latest compatible version, including its own dependencies.',
|
||||
'admin.plugins.dep.trekIncompatible': 'Needs TREK {range} — this server runs {host}',
|
||||
'admin.plugins.dep.trekUnknown': 'Does not say which TREK versions it supports',
|
||||
'admin.plugins.installCompatible': 'Install {version}',
|
||||
'admin.plugins.incompatible': 'Incompatible',
|
||||
'admin.plugins.accessTitle': 'What it can access',
|
||||
'admin.plugins.connectsTitle': 'Connects to',
|
||||
'admin.plugins.detailsTitle': 'Details',
|
||||
|
||||
@@ -187,14 +187,17 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.viewErrors': 'Ver registro de errores',
|
||||
'admin.plugins.errorLog': 'Registro de errores',
|
||||
'admin.plugins.allowedHosts': 'Hosts permitidos',
|
||||
'admin.plugins.allowedHosts.hint': 'Este plugin se comunica con un servicio que solo tú puedes nombrar (un servidor autoalojado). Añade los hosts a los que puede acceder — no accederá a ningún otro.',
|
||||
'admin.plugins.allowedHosts.hint':
|
||||
'Este plugin se comunica con un servicio que solo tú puedes nombrar (un servidor autoalojado). Añade los hosts a los que puede acceder — no accederá a ningún otro.',
|
||||
'admin.plugins.allowedHosts.none': 'Aún no se han añadido hosts.',
|
||||
'admin.plugins.allowedHosts.unsupported': 'Este plugin no usa hosts proporcionados por el operador. Sus hosts permitidos están fijados en su manifiesto.',
|
||||
'admin.plugins.allowedHosts.unsupported':
|
||||
'Este plugin no usa hosts proporcionados por el operador. Sus hosts permitidos están fijados en su manifiesto.',
|
||||
'admin.plugins.allowedHosts.restartNote': 'Al guardar se reinicia el plugin para que tome la nueva lista.',
|
||||
'admin.plugins.allowedHosts.add': 'Añadir host permitido',
|
||||
'admin.plugins.allowedHosts.count': '{n} host(s) permitido(s)',
|
||||
'admin.plugins.operatorEgressPill': '+ hosts que añadas',
|
||||
'admin.plugins.operatorEgressHint': 'Este plugin se comunica con un servicio que solo tú puedes nombrar (un servidor autoalojado). Tras instalarlo, añade los hosts a los que puede acceder en ⋯ → Hosts permitidos. No accederá a ningún otro.',
|
||||
'admin.plugins.operatorEgressHint':
|
||||
'Este plugin se comunica con un servicio que solo tú puedes nombrar (un servidor autoalojado). Tras instalarlo, añade los hosts a los que puede acceder en ⋯ → Hosts permitidos. No accederá a ningún otro.',
|
||||
'admin.plugins.noErrors': 'No hay errores registrados.',
|
||||
'admin.plugins.uninstalled': 'Plugin desinstalado',
|
||||
'admin.plugins.uninstallTitle': '¿Desinstalar el plugin?',
|
||||
@@ -361,6 +364,34 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.security.reviewedTitle': 'Qué significa "Revisado"',
|
||||
'admin.plugins.security.reviewedBody':
|
||||
'Un plugin revisado fue analizado manualmente en busca de malware por un responsable de TREK en cada versión — se comprobó que no tuviera código malicioso, no si funciona bien. No es una promesa de que el plugin sea inofensivo.',
|
||||
'admin.plugins.security.signedTitle': 'Qué significa "Firmado"',
|
||||
'admin.plugins.security.signedBody':
|
||||
'La suma de comprobación que TREK verifica en cada instalación demuestra que los archivos son exactamente los que el registro avala. Una firma demuestra otra cosa: que provienen del autor, firmados con una clave que solo él posee. Un plugin firmado tiene ambas cosas. Uno sin firmar no es inseguro — simplemente lleva una garantía menos, y hoy la mayoría de los plugins del registro no están firmados.',
|
||||
'admin.plugins.signed': 'Firmado',
|
||||
'admin.plugins.signedHint': 'Verificado con la clave de firma del autor en el momento de la instalación',
|
||||
'admin.plugins.unsigned': 'Sin firmar',
|
||||
'admin.plugins.unsignedHint':
|
||||
'Los archivos coinciden con lo que el registro avala, pero nada los vincula con el autor. Una garantía menos — no es inseguro.',
|
||||
'admin.plugins.updateBlocked': 'Actualización bloqueada — {reason}',
|
||||
'admin.plugins.reviewBlock': 'Revisar',
|
||||
'admin.plugins.retrusted': 'Nueva clave de firma aceptada — el plugin está actualizado',
|
||||
'admin.plugins.sig.title': 'No se pudo verificar la firma de {name}',
|
||||
'admin.plugins.sig.keyChangedBody':
|
||||
'La clave de firma del autor no es la misma con la que se instaló este plugin. Los autores sí rotan sus claves — pero un atacante que se hubiera apoderado del plugin se vería exactamente así.',
|
||||
'admin.plugins.sig.invalidBody':
|
||||
'Los archivos no coinciden con la firma del autor. No son lo que el autor firmó — o se corrompieron, o fueron manipulados. Esto no se puede omitir.',
|
||||
'admin.plugins.sig.missingBody':
|
||||
'Este plugin estaba firmado cuando lo instalaste, pero la nueva versión no incluye ninguna firma. TREK no aceptará esa degradación en silencio. Esto no se puede omitir.',
|
||||
'admin.plugins.sig.incompleteBody':
|
||||
'La entrada del registro está firmada a medias: declara una clave de autor pero la versión no lleva firma (o al revés). Es un error del lado del plugin. Esto no se puede omitir.',
|
||||
'admin.plugins.sig.pinnedKey': 'Clave con la que se instaló',
|
||||
'admin.plugins.sig.newKey': 'Clave que ofrece ahora',
|
||||
'admin.plugins.sig.confirmOutOfBand':
|
||||
'TREK no puede distinguir una rotación de clave legítima de un secuestro — desde aquí ambas se ven idénticas. Confirma la nueva clave con el autor por un canal en el que ya confíes antes de aceptarla. Cuando lo hagas, el plugin se actualizará y la nueva clave quedará registrada.',
|
||||
'admin.plugins.sig.retrustConfirm': 'Confiar en la nueva clave y actualizar',
|
||||
'admin.plugins.sig.cancel': 'No confiar',
|
||||
'admin.plugins.sig.consentUnsigned':
|
||||
'Nada vincula esta versión con su autor — los archivos coinciden con el registro, pero no llevan ninguna firma del autor.',
|
||||
'admin.plugins.security.trustTitle': 'En resumen',
|
||||
'admin.plugins.security.trustBody':
|
||||
'Instalar un plugin es como instalar cualquier aplicación de terceros: añade únicamente código de autores en los que confíes y, en caso de duda, inspecciónalo tú mismo primero. TREK no asume ninguna responsabilidad por los plugins de terceros.',
|
||||
@@ -420,6 +451,10 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.dep.download': 'Descargar',
|
||||
'admin.plugins.dep.update': 'Actualizar',
|
||||
'admin.plugins.dep.resolveHint': 'Descarga la última versión compatible, incluidas sus propias dependencias.',
|
||||
'admin.plugins.dep.trekIncompatible': 'Necesita TREK {range} — este servidor ejecuta {host}',
|
||||
'admin.plugins.dep.trekUnknown': 'No indica qué versiones de TREK admite',
|
||||
'admin.plugins.installCompatible': 'Instalar {version}',
|
||||
'admin.plugins.incompatible': 'Incompatible',
|
||||
'admin.plugins.accessTitle': 'A qué puede acceder',
|
||||
'admin.plugins.connectsTitle': 'Se conecta a',
|
||||
'admin.plugins.detailsTitle': 'Detalles',
|
||||
|
||||
@@ -298,7 +298,8 @@ const settings: TranslationStrings = {
|
||||
'settings.notificationPreferences.configure': 'Configurar',
|
||||
'settings.notificationPreferences.testSuccess': 'Notificación de prueba enviada.',
|
||||
'settings.notificationPreferences.testFailed': 'La prueba falló.',
|
||||
'settings.notificationPreferences.pluginConfigured': 'Configurado. Gestiona tus credenciales en la página de ajustes del plugin.',
|
||||
'settings.notificationPreferences.pluginConfigured':
|
||||
'Configurado. Gestiona tus credenciales en la página de ajustes del plugin.',
|
||||
'settings.notificationPreferences.webhook': 'Webhook',
|
||||
'settings.notificationPreferences.email': 'Email',
|
||||
'settings.notificationPreferences.ntfy': 'Ntfy',
|
||||
|
||||
@@ -189,14 +189,18 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.viewErrors': "Voir le journal d'erreurs",
|
||||
'admin.plugins.errorLog': "Journal d'erreurs",
|
||||
'admin.plugins.allowedHosts': 'Hôtes autorisés',
|
||||
'admin.plugins.allowedHosts.hint': 'Ce plugin communique avec un service que vous seul pouvez nommer (un serveur auto-hébergé). Ajoutez les hôtes qu’il peut joindre — il ne joindra aucun autre.',
|
||||
'admin.plugins.allowedHosts.hint':
|
||||
'Ce plugin communique avec un service que vous seul pouvez nommer (un serveur auto-hébergé). Ajoutez les hôtes qu’il peut joindre — il ne joindra aucun autre.',
|
||||
'admin.plugins.allowedHosts.none': 'Aucun hôte ajouté pour l’instant.',
|
||||
'admin.plugins.allowedHosts.unsupported': 'Ce plugin n’utilise pas d’hôtes fournis par l’opérateur. Ses hôtes autorisés sont fixés dans son manifeste.',
|
||||
'admin.plugins.allowedHosts.restartNote': 'L’enregistrement redémarre le plugin pour qu’il prenne en compte la nouvelle liste.',
|
||||
'admin.plugins.allowedHosts.unsupported':
|
||||
'Ce plugin n’utilise pas d’hôtes fournis par l’opérateur. Ses hôtes autorisés sont fixés dans son manifeste.',
|
||||
'admin.plugins.allowedHosts.restartNote':
|
||||
'L’enregistrement redémarre le plugin pour qu’il prenne en compte la nouvelle liste.',
|
||||
'admin.plugins.allowedHosts.add': 'Ajouter un hôte autorisé',
|
||||
'admin.plugins.allowedHosts.count': '{n} hôte(s) autorisé(s)',
|
||||
'admin.plugins.operatorEgressPill': '+ hôtes que vous ajoutez',
|
||||
'admin.plugins.operatorEgressHint': 'Ce plugin communique avec un service que vous seul pouvez nommer (un serveur auto-hébergé). Après installation, ajoutez les hôtes qu’il peut joindre via ⋯ → Hôtes autorisés. Il ne joindra aucun autre.',
|
||||
'admin.plugins.operatorEgressHint':
|
||||
'Ce plugin communique avec un service que vous seul pouvez nommer (un serveur auto-hébergé). Après installation, ajoutez les hôtes qu’il peut joindre via ⋯ → Hôtes autorisés. Il ne joindra aucun autre.',
|
||||
'admin.plugins.noErrors': 'Aucune erreur enregistrée.',
|
||||
'admin.plugins.uninstalled': 'Plugin désinstallé',
|
||||
'admin.plugins.uninstallTitle': 'Désinstaller le plugin ?',
|
||||
@@ -362,6 +366,34 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.security.reviewedTitle': 'Ce que signifie « Vérifié »',
|
||||
'admin.plugins.security.reviewedBody':
|
||||
"Un plugin vérifié a été analysé manuellement à la recherche de logiciels malveillants par un mainteneur de TREK à chaque version — contrôlé pour du code hostile, non pour son bon fonctionnement. Ce n'est pas une promesse que le plugin est inoffensif.",
|
||||
'admin.plugins.security.signedTitle': 'Ce que signifie « Signé »',
|
||||
'admin.plugins.security.signedBody':
|
||||
"La somme de contrôle que TREK vérifie à chaque installation prouve que les fichiers sont exactement ceux dont le registre se porte garant. Une signature prouve autre chose : qu'ils proviennent de l'auteur, signés avec une clé que lui seul détient. Un plugin signé possède les deux. Un plugin non signé n'est pas dangereux — il porte simplement une garantie de moins, et la plupart des plugins du registre ne sont pas signés aujourd'hui.",
|
||||
'admin.plugins.signed': 'Signé',
|
||||
'admin.plugins.signedHint': "Vérifié avec la clé de signature de l'auteur au moment de l'installation",
|
||||
'admin.plugins.unsigned': 'Non signé',
|
||||
'admin.plugins.unsignedHint':
|
||||
"Les fichiers correspondent à ce dont le registre se porte garant, mais rien ne les relie à l'auteur. Une garantie de moins — pas un danger.",
|
||||
'admin.plugins.updateBlocked': 'Mise à jour bloquée — {reason}',
|
||||
'admin.plugins.reviewBlock': 'Examiner',
|
||||
'admin.plugins.retrusted': 'Nouvelle clé de signature approuvée — le plugin est mis à jour',
|
||||
'admin.plugins.sig.title': "La signature de {name} n'a pas pu être vérifiée",
|
||||
'admin.plugins.sig.keyChangedBody':
|
||||
"La clé de signature de l'auteur n'est pas celle avec laquelle ce plugin a été installé. Les auteurs changent bel et bien de clé — mais un attaquant ayant pris le contrôle du plugin ressemblerait exactement à ceci.",
|
||||
'admin.plugins.sig.invalidBody':
|
||||
"Les fichiers ne correspondent pas à la signature de l'auteur. Ce n'est pas ce que l'auteur a signé — soit ils ont été corrompus, soit ils ont été altérés. Impossible de passer outre.",
|
||||
'admin.plugins.sig.missingBody':
|
||||
"Ce plugin était signé lorsque vous l'avez installé, mais la nouvelle version ne fournit aucune signature. TREK n'acceptera pas ce recul en silence. Impossible de passer outre.",
|
||||
'admin.plugins.sig.incompleteBody':
|
||||
"L'entrée du registre est à moitié signée : elle déclare une clé d'auteur mais la version ne porte aucune signature (ou l'inverse). C'est une erreur du côté du plugin. Impossible de passer outre.",
|
||||
'admin.plugins.sig.pinnedKey': "Clé utilisée lors de l'installation",
|
||||
'admin.plugins.sig.newKey': "Clé proposée aujourd'hui",
|
||||
'admin.plugins.sig.confirmOutOfBand':
|
||||
"TREK ne peut pas distinguer un changement de clé légitime d'une prise de contrôle — vus d'ici, les deux sont identiques. Confirmez la nouvelle clé auprès de l'auteur via un canal auquel vous faites déjà confiance avant de l'accepter. Une fois que c'est fait, le plugin est mis à jour et la nouvelle clé est mémorisée.",
|
||||
'admin.plugins.sig.retrustConfirm': 'Faire confiance à la nouvelle clé et mettre à jour',
|
||||
'admin.plugins.sig.cancel': 'Ne pas lui faire confiance',
|
||||
'admin.plugins.sig.consentUnsigned':
|
||||
"Rien ne relie cette version à son auteur — les fichiers correspondent au registre, mais ne portent aucune signature d'auteur.",
|
||||
'admin.plugins.security.trustTitle': 'En résumé',
|
||||
'admin.plugins.security.trustBody':
|
||||
"Installer un plugin revient à installer n'importe quelle application tierce : n'ajoutez que du code provenant d'auteurs de confiance et, en cas de doute, inspectez-le vous-même au préalable. TREK décline toute responsabilité concernant les plugins tiers.",
|
||||
@@ -421,6 +453,10 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.dep.download': 'Télécharger',
|
||||
'admin.plugins.dep.update': 'Mettre à jour',
|
||||
'admin.plugins.dep.resolveHint': 'Télécharge la dernière version compatible, y compris ses propres dépendances.',
|
||||
'admin.plugins.dep.trekIncompatible': 'Nécessite TREK {range} — ce serveur exécute {host}',
|
||||
'admin.plugins.dep.trekUnknown': 'N’indique pas quelles versions de TREK sont prises en charge',
|
||||
'admin.plugins.installCompatible': 'Installer {version}',
|
||||
'admin.plugins.incompatible': 'Incompatible',
|
||||
'admin.plugins.accessTitle': 'Ce à quoi il peut accéder',
|
||||
'admin.plugins.connectsTitle': 'Se connecte à',
|
||||
'admin.plugins.detailsTitle': 'Détails',
|
||||
|
||||
@@ -301,7 +301,8 @@ const settings: TranslationStrings = {
|
||||
'settings.notificationPreferences.configure': 'Configurer',
|
||||
'settings.notificationPreferences.testSuccess': 'Notification de test envoyée.',
|
||||
'settings.notificationPreferences.testFailed': 'Échec du test.',
|
||||
'settings.notificationPreferences.pluginConfigured': 'Configuré. Gérez vos identifiants sur la page de réglages du plugin.',
|
||||
'settings.notificationPreferences.pluginConfigured':
|
||||
'Configuré. Gérez vos identifiants sur la page de réglages du plugin.',
|
||||
'settings.notificationPreferences.webhook': 'Webhook',
|
||||
'settings.notificationPreferences.email': 'Email',
|
||||
'settings.notificationPreferences.ntfy': 'Ntfy',
|
||||
|
||||
@@ -234,14 +234,17 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.viewErrors': 'Προβολή αρχείου σφαλμάτων',
|
||||
'admin.plugins.errorLog': 'Αρχείο σφαλμάτων',
|
||||
'admin.plugins.allowedHosts': 'Επιτρεπόμενοι κόμβοι',
|
||||
'admin.plugins.allowedHosts.hint': 'Αυτό το πρόσθετο επικοινωνεί με μια υπηρεσία που μόνο εσείς μπορείτε να ονομάσετε (αυτο-φιλοξενούμενος διακομιστής). Προσθέστε τους κόμβους που μπορεί να προσεγγίσει — κανέναν άλλον.',
|
||||
'admin.plugins.allowedHosts.hint':
|
||||
'Αυτό το πρόσθετο επικοινωνεί με μια υπηρεσία που μόνο εσείς μπορείτε να ονομάσετε (αυτο-φιλοξενούμενος διακομιστής). Προσθέστε τους κόμβους που μπορεί να προσεγγίσει — κανέναν άλλον.',
|
||||
'admin.plugins.allowedHosts.none': 'Δεν έχουν προστεθεί κόμβοι ακόμη.',
|
||||
'admin.plugins.allowedHosts.unsupported': 'Αυτό το πρόσθετο δεν χρησιμοποιεί κόμβους που ορίζει ο διαχειριστής. Οι επιτρεπόμενοι κόμβοι του είναι σταθεροί στο manifest.',
|
||||
'admin.plugins.allowedHosts.unsupported':
|
||||
'Αυτό το πρόσθετο δεν χρησιμοποιεί κόμβους που ορίζει ο διαχειριστής. Οι επιτρεπόμενοι κόμβοι του είναι σταθεροί στο manifest.',
|
||||
'admin.plugins.allowedHosts.restartNote': 'Η αποθήκευση επανεκκινεί το πρόσθετο ώστε να λάβει τη νέα λίστα.',
|
||||
'admin.plugins.allowedHosts.add': 'Προσθήκη επιτρεπόμενου κόμβου',
|
||||
'admin.plugins.allowedHosts.count': '{n} επιτρεπόμενοι κόμβοι',
|
||||
'admin.plugins.operatorEgressPill': '+ κόμβοι που προσθέτετε',
|
||||
'admin.plugins.operatorEgressHint': 'Αυτό το πρόσθετο επικοινωνεί με μια υπηρεσία που μόνο εσείς μπορείτε να ονομάσετε (αυτο-φιλοξενούμενος διακομιστής). Μετά την εγκατάσταση προσθέστε τους κόμβους στο ⋯ → Επιτρεπόμενοι κόμβοι. Κανέναν άλλον.',
|
||||
'admin.plugins.operatorEgressHint':
|
||||
'Αυτό το πρόσθετο επικοινωνεί με μια υπηρεσία που μόνο εσείς μπορείτε να ονομάσετε (αυτο-φιλοξενούμενος διακομιστής). Μετά την εγκατάσταση προσθέστε τους κόμβους στο ⋯ → Επιτρεπόμενοι κόμβοι. Κανέναν άλλον.',
|
||||
'admin.plugins.noErrors': 'Δεν έχουν καταγραφεί σφάλματα.',
|
||||
'admin.plugins.uninstalled': 'Το plugin απεγκαταστάθηκε',
|
||||
'admin.plugins.uninstallTitle': 'Απεγκατάσταση plugin;',
|
||||
@@ -412,6 +415,34 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.security.reviewedTitle': 'Τι σημαίνει το "Ελεγμένο"',
|
||||
'admin.plugins.security.reviewedBody':
|
||||
'Ένα ελεγμένο plugin σαρώθηκε χειροκίνητα για κακόβουλο λογισμικό από έναν συντηρητή του TREK σε κάθε έκδοση — ελέγχθηκε για εχθρικό κώδικα, όχι για το αν λειτουργεί καλά. Δεν αποτελεί υπόσχεση ότι το plugin είναι ακίνδυνο.',
|
||||
'admin.plugins.security.signedTitle': 'Τι σημαίνει το "Υπογεγραμμένο"',
|
||||
'admin.plugins.security.signedBody':
|
||||
'Το checksum που επαληθεύει το TREK σε κάθε εγκατάσταση αποδεικνύει ότι τα αρχεία είναι ακριβώς αυτά για τα οποία εγγυάται το μητρώο. Μια υπογραφή αποδεικνύει κάτι διαφορετικό: ότι προέρχονται από τον δημιουργό, υπογεγραμμένα με ένα κλειδί που κατέχει μόνο εκείνος. Ένα υπογεγραμμένο plugin έχει και τα δύο. Ένα ανυπόγραφο δεν είναι επικίνδυνο — απλώς φέρει μία εγγύηση λιγότερη, και τα περισσότερα plugins στο μητρώο είναι σήμερα ανυπόγραφα.',
|
||||
'admin.plugins.signed': 'Υπογεγραμμένο',
|
||||
'admin.plugins.signedHint': 'Επαληθεύτηκε με το κλειδί υπογραφής του δημιουργού κατά την εγκατάσταση',
|
||||
'admin.plugins.unsigned': 'Ανυπόγραφο',
|
||||
'admin.plugins.unsignedHint':
|
||||
'Τα αρχεία ταιριάζουν με αυτά για τα οποία εγγυάται το μητρώο, αλλά τίποτα δεν τα συνδέει με τον δημιουργό. Μία εγγύηση λιγότερη — όχι επικίνδυνο.',
|
||||
'admin.plugins.updateBlocked': 'Η ενημέρωση μπλοκαρίστηκε — {reason}',
|
||||
'admin.plugins.reviewBlock': 'Έλεγχος',
|
||||
'admin.plugins.retrusted': 'Το νέο κλειδί υπογραφής έγινε αποδεκτό — το plugin ενημερώθηκε',
|
||||
'admin.plugins.sig.title': 'Η υπογραφή του {name} δεν μπόρεσε να επαληθευτεί',
|
||||
'admin.plugins.sig.keyChangedBody':
|
||||
'Το κλειδί υπογραφής του δημιουργού δεν είναι αυτό με το οποίο εγκαταστάθηκε αυτό το plugin. Οι δημιουργοί όντως αλλάζουν κλειδιά — αλλά ένας επιτιθέμενος που θα είχε καταλάβει το plugin θα έμοιαζε ακριβώς έτσι.',
|
||||
'admin.plugins.sig.invalidBody':
|
||||
'Τα αρχεία δεν ταιριάζουν με την υπογραφή του δημιουργού. Δεν είναι αυτά που υπέγραψε ο δημιουργός — είτε αλλοιώθηκαν, είτε παραποιήθηκαν. Αυτό δεν μπορεί να παρακαμφθεί.',
|
||||
'admin.plugins.sig.missingBody':
|
||||
'Αυτό το plugin ήταν υπογεγραμμένο όταν το εγκαταστήσατε, αλλά η νέα έκδοση δεν περιλαμβάνει καμία υπογραφή. Το TREK δεν θα δεχτεί σιωπηλά αυτή την υποβάθμιση. Αυτό δεν μπορεί να παρακαμφθεί.',
|
||||
'admin.plugins.sig.incompleteBody':
|
||||
'Η καταχώριση στο μητρώο είναι μισοϋπογεγραμμένη: δηλώνει κλειδί δημιουργού αλλά η έκδοση δεν φέρει υπογραφή (ή το αντίστροφο). Αυτό είναι σφάλμα από την πλευρά του plugin. Αυτό δεν μπορεί να παρακαμφθεί.',
|
||||
'admin.plugins.sig.pinnedKey': 'Κλειδί με το οποίο εγκαταστάθηκε',
|
||||
'admin.plugins.sig.newKey': 'Κλειδί που προσφέρει τώρα',
|
||||
'admin.plugins.sig.confirmOutOfBand':
|
||||
'Το TREK δεν μπορεί να ξεχωρίσει μια νόμιμη αλλαγή κλειδιού από μια κατάληψη — από εδώ φαίνονται πανομοιότυπες. Επιβεβαιώστε το νέο κλειδί με τον δημιουργό μέσω ενός καναλιού που ήδη εμπιστεύεστε, προτού το αποδεχτείτε. Μόλις το κάνετε, το plugin ενημερώνεται και το νέο κλειδί απομνημονεύεται.',
|
||||
'admin.plugins.sig.retrustConfirm': 'Εμπιστοσύνη στο νέο κλειδί & ενημέρωση',
|
||||
'admin.plugins.sig.cancel': 'Να μην γίνει αποδεκτό',
|
||||
'admin.plugins.sig.consentUnsigned':
|
||||
'Τίποτα δεν συνδέει αυτή την έκδοση με τον δημιουργό της — τα αρχεία ταιριάζουν με το μητρώο, αλλά δεν φέρουν υπογραφή δημιουργού.',
|
||||
'admin.plugins.security.trustTitle': 'Το συμπέρασμα',
|
||||
'admin.plugins.security.trustBody':
|
||||
'Η εγκατάσταση ενός plugin είναι σαν την εγκατάσταση οποιασδήποτε εφαρμογής τρίτου: προσθέστε κώδικα μόνο από δημιουργούς που εμπιστεύεστε, και σε περίπτωση αμφιβολίας, ελέγξτε τον πρώτα μόνοι σας. Το TREK δεν αναλαμβάνει καμία ευθύνη για plugins τρίτων.',
|
||||
@@ -471,6 +502,10 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.dep.download': 'Λήψη',
|
||||
'admin.plugins.dep.update': 'Ενημέρωση',
|
||||
'admin.plugins.dep.resolveHint': 'Λαμβάνει την πιο πρόσφατη συμβατή έκδοση, μαζί με τις δικές της εξαρτήσεις.',
|
||||
'admin.plugins.dep.trekIncompatible': 'Απαιτεί TREK {range} — αυτός ο διακομιστής εκτελεί {host}',
|
||||
'admin.plugins.dep.trekUnknown': 'Δεν δηλώνει ποιες εκδόσεις TREK υποστηρίζει',
|
||||
'admin.plugins.installCompatible': 'Εγκατάσταση {version}',
|
||||
'admin.plugins.incompatible': 'Μη συμβατό',
|
||||
'admin.plugins.accessTitle': 'Σε τι έχει πρόσβαση',
|
||||
'admin.plugins.connectsTitle': 'Συνδέεται σε',
|
||||
'admin.plugins.detailsTitle': 'Λεπτομέρειες',
|
||||
|
||||
@@ -104,7 +104,8 @@ const settings: TranslationStrings = {
|
||||
'settings.notificationPreferences.configure': 'Ρύθμιση',
|
||||
'settings.notificationPreferences.testSuccess': 'Στάλθηκε δοκιμαστική ειδοποίηση.',
|
||||
'settings.notificationPreferences.testFailed': 'Η δοκιμή απέτυχε.',
|
||||
'settings.notificationPreferences.pluginConfigured': 'Ρυθμίστηκε. Διαχειριστείτε τα διαπιστευτήρια στη σελίδα ρυθμίσεων του πρόσθετου.',
|
||||
'settings.notificationPreferences.pluginConfigured':
|
||||
'Ρυθμίστηκε. Διαχειριστείτε τα διαπιστευτήρια στη σελίδα ρυθμίσεων του πρόσθετου.',
|
||||
'settings.notificationPreferences.ntfy': 'Ntfy',
|
||||
'settings.notificationPreferences.noChannels':
|
||||
'Δεν έχουν διαμορφωθεί κανάλια ειδοποιήσεων. Ζητήστε από έναν διαχειριστή να ρυθμίσει ειδοποιήσεις email ή webhook.',
|
||||
|
||||
@@ -188,14 +188,17 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.viewErrors': 'Hibanapló megtekintése',
|
||||
'admin.plugins.errorLog': 'Hibanapló',
|
||||
'admin.plugins.allowedHosts': 'Engedélyezett hosztok',
|
||||
'admin.plugins.allowedHosts.hint': 'Ez a bővítmény olyan szolgáltatással kommunikál, amelyet csak te tudsz megnevezni (saját üzemeltetésű kiszolgáló). Add meg a hosztokat, amelyeket elérhet — másokat nem.',
|
||||
'admin.plugins.allowedHosts.hint':
|
||||
'Ez a bővítmény olyan szolgáltatással kommunikál, amelyet csak te tudsz megnevezni (saját üzemeltetésű kiszolgáló). Add meg a hosztokat, amelyeket elérhet — másokat nem.',
|
||||
'admin.plugins.allowedHosts.none': 'Még nincs hozzáadott hoszt.',
|
||||
'admin.plugins.allowedHosts.unsupported': 'Ez a bővítmény nem használ üzemeltető által megadott hosztokat. Az engedélyezett hosztjai a manifestben rögzítettek.',
|
||||
'admin.plugins.allowedHosts.unsupported':
|
||||
'Ez a bővítmény nem használ üzemeltető által megadott hosztokat. Az engedélyezett hosztjai a manifestben rögzítettek.',
|
||||
'admin.plugins.allowedHosts.restartNote': 'A mentés újraindítja a bővítményt, hogy átvegye az új listát.',
|
||||
'admin.plugins.allowedHosts.add': 'Engedélyezett hoszt hozzáadása',
|
||||
'admin.plugins.allowedHosts.count': '{n} engedélyezett hoszt',
|
||||
'admin.plugins.operatorEgressPill': '+ általad hozzáadott hosztok',
|
||||
'admin.plugins.operatorEgressHint': 'Ez a bővítmény olyan szolgáltatással kommunikál, amelyet csak te tudsz megnevezni (saját üzemeltetésű kiszolgáló). Telepítés után add meg a hosztokat a ⋯ → Engedélyezett hosztok alatt. Másokat nem ér el.',
|
||||
'admin.plugins.operatorEgressHint':
|
||||
'Ez a bővítmény olyan szolgáltatással kommunikál, amelyet csak te tudsz megnevezni (saját üzemeltetésű kiszolgáló). Telepítés után add meg a hosztokat a ⋯ → Engedélyezett hosztok alatt. Másokat nem ér el.',
|
||||
'admin.plugins.noErrors': 'Nincsenek naplózott hibák.',
|
||||
'admin.plugins.uninstalled': 'Plugin eltávolítva',
|
||||
'admin.plugins.uninstallTitle': 'Plugin eltávolítása?',
|
||||
@@ -364,6 +367,34 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.security.reviewedTitle': 'Mit jelent az „Ellenőrizve"',
|
||||
'admin.plugins.security.reviewedBody':
|
||||
'Egy ellenőrzött plugint egy TREK karbantartó minden verziónál kézzel átvizsgált kártevők után — ellenséges kód szempontjából ellenőrizte, nem pedig azt, hogy jól működik-e. Ez nem ígéret arra, hogy a plugin ártalmatlan.',
|
||||
'admin.plugins.security.signedTitle': 'Mit jelent az „Aláírva"',
|
||||
'admin.plugins.security.signedBody':
|
||||
'Az ellenőrzőösszeg, amelyet a TREK minden telepítéskor ellenőriz, azt bizonyítja, hogy a fájlok pontosan azok, amelyekért a jegyzék jótáll. Az aláírás mást bizonyít: azt, hogy a szerzőtől származnak, olyan kulccsal aláírva, amelyet csak ő birtokol. Egy aláírt plugin mindkettővel rendelkezik. Az aláírás nélküli nem veszélyes — egyszerűen eggyel kevesebb garanciát hordoz, és a jegyzékben ma a legtöbb plugin aláírás nélküli.',
|
||||
'admin.plugins.signed': 'Aláírva',
|
||||
'admin.plugins.signedHint': 'Telepítéskor ellenőrizve a szerző aláírókulcsával',
|
||||
'admin.plugins.unsigned': 'Aláírás nélkül',
|
||||
'admin.plugins.unsignedHint':
|
||||
'A fájlok megegyeznek azzal, amiért a jegyzék jótáll, de semmi sem köti őket a szerzőhöz. Eggyel kevesebb garancia — nem veszélyes.',
|
||||
'admin.plugins.updateBlocked': 'Frissítés blokkolva — {reason}',
|
||||
'admin.plugins.reviewBlock': 'Áttekintés',
|
||||
'admin.plugins.retrusted': 'Az új aláírókulcs elfogadva — a plugin frissült',
|
||||
'admin.plugins.sig.title': 'A(z) {name} aláírását nem sikerült ellenőrizni',
|
||||
'admin.plugins.sig.keyChangedBody':
|
||||
'A szerző aláírókulcsa nem az, amellyel ezt a plugint telepítetted. A szerzők valóban cserélnek kulcsot — de egy támadó, aki átvette a plugint, pontosan így nézne ki.',
|
||||
'admin.plugins.sig.invalidBody':
|
||||
'A fájlok nem egyeznek a szerző aláírásával. Nem azok, amiket a szerző aláírt — vagy megsérültek, vagy módosították őket. Ez nem hagyható figyelmen kívül.',
|
||||
'admin.plugins.sig.missingBody':
|
||||
'Ez a plugin alá volt írva, amikor telepítetted, de az új verzió nem tartalmaz aláírást. A TREK nem fogadja el csendben ezt a visszalépést. Ez nem hagyható figyelmen kívül.',
|
||||
'admin.plugins.sig.incompleteBody':
|
||||
'A jegyzékbeli bejegyzés félig aláírt: szerzői kulcsot deklarál, de a verzió nem hordoz aláírást (vagy fordítva). Ez a plugin oldalán elkövetett hiba. Ez nem hagyható figyelmen kívül.',
|
||||
'admin.plugins.sig.pinnedKey': 'A telepítéskor használt kulcs',
|
||||
'admin.plugins.sig.newKey': 'A most kínált kulcs',
|
||||
'admin.plugins.sig.confirmOutOfBand':
|
||||
'A TREK nem tudja megkülönböztetni a jogos kulcscserét az átvételtől — innen nézve a kettő azonos. Erősítsd meg az új kulcsot a szerzőnél egy olyan csatornán, amelyben már megbízol, mielőtt elfogadod. Amint ezt megteszed, a plugin frissül, és a TREK megjegyzi az új kulcsot.',
|
||||
'admin.plugins.sig.retrustConfirm': 'Új kulcs megbízhatónak jelölése és frissítés',
|
||||
'admin.plugins.sig.cancel': 'Ne bízz meg benne',
|
||||
'admin.plugins.sig.consentUnsigned':
|
||||
'Semmi sem köti ezt a verziót a szerzőjéhez — a fájlok megegyeznek a jegyzékkel, de nem hordoznak szerzői aláírást.',
|
||||
'admin.plugins.security.trustTitle': 'A lényeg',
|
||||
'admin.plugins.security.trustBody':
|
||||
'Egy plugin telepítése olyan, mint bármely harmadik féltől származó alkalmazás telepítése: csak megbízható szerzőktől adj hozzá kódot, és kétség esetén előbb magad vizsgáld meg. A TREK semmilyen felelősséget nem vállal a harmadik féltől származó pluginokért.',
|
||||
@@ -423,6 +454,10 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.dep.download': 'Letöltés',
|
||||
'admin.plugins.dep.update': 'Frissítés',
|
||||
'admin.plugins.dep.resolveHint': 'Letölti a legújabb kompatibilis verziót a saját függőségeivel együtt.',
|
||||
'admin.plugins.dep.trekIncompatible': 'TREK {range} szükséges — ez a kiszolgáló {host} verziót futtat',
|
||||
'admin.plugins.dep.trekUnknown': 'Nem adja meg, mely TREK-verziókat támogatja',
|
||||
'admin.plugins.installCompatible': '{version} telepítése',
|
||||
'admin.plugins.incompatible': 'Nem kompatibilis',
|
||||
'admin.plugins.accessTitle': 'Mihez fér hozzá',
|
||||
'admin.plugins.connectsTitle': 'Kapcsolódik ehhez',
|
||||
'admin.plugins.detailsTitle': 'Részletek',
|
||||
|
||||
@@ -297,7 +297,8 @@ const settings: TranslationStrings = {
|
||||
'settings.notificationPreferences.configure': 'Beállítás',
|
||||
'settings.notificationPreferences.testSuccess': 'Teszt értesítés elküldve.',
|
||||
'settings.notificationPreferences.testFailed': 'A teszt sikertelen.',
|
||||
'settings.notificationPreferences.pluginConfigured': 'Beállítva. A hitelesítő adatok a bővítmény beállításainál kezelhetők.',
|
||||
'settings.notificationPreferences.pluginConfigured':
|
||||
'Beállítva. A hitelesítő adatok a bővítmény beállításainál kezelhetők.',
|
||||
'settings.notificationPreferences.webhook': 'Webhook',
|
||||
'settings.notificationPreferences.email': 'Email',
|
||||
'settings.notificationPreferences.ntfy': 'Ntfy',
|
||||
|
||||
@@ -230,14 +230,17 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.viewErrors': 'Lihat log error',
|
||||
'admin.plugins.errorLog': 'Log error',
|
||||
'admin.plugins.allowedHosts': 'Host yang diizinkan',
|
||||
'admin.plugins.allowedHosts.hint': 'Plugin ini berbicara dengan layanan yang hanya Anda yang tahu namanya (server swakelola). Tambahkan host yang boleh dijangkau — selain itu tidak bisa.',
|
||||
'admin.plugins.allowedHosts.hint':
|
||||
'Plugin ini berbicara dengan layanan yang hanya Anda yang tahu namanya (server swakelola). Tambahkan host yang boleh dijangkau — selain itu tidak bisa.',
|
||||
'admin.plugins.allowedHosts.none': 'Belum ada host yang ditambahkan.',
|
||||
'admin.plugins.allowedHosts.unsupported': 'Plugin ini tidak memakai host dari operator. Host yang diizinkan sudah tetap di manifesnya.',
|
||||
'admin.plugins.allowedHosts.unsupported':
|
||||
'Plugin ini tidak memakai host dari operator. Host yang diizinkan sudah tetap di manifesnya.',
|
||||
'admin.plugins.allowedHosts.restartNote': 'Menyimpan akan memulai ulang plugin agar memakai daftar baru.',
|
||||
'admin.plugins.allowedHosts.add': 'Tambah host yang diizinkan',
|
||||
'admin.plugins.allowedHosts.count': '{n} host diizinkan',
|
||||
'admin.plugins.operatorEgressPill': '+ host yang Anda tambahkan',
|
||||
'admin.plugins.operatorEgressHint': 'Plugin ini berbicara dengan layanan yang hanya Anda yang tahu namanya (server swakelola). Setelah dipasang, tambahkan host di ⋯ → Host yang diizinkan. Selain itu tidak bisa.',
|
||||
'admin.plugins.operatorEgressHint':
|
||||
'Plugin ini berbicara dengan layanan yang hanya Anda yang tahu namanya (server swakelola). Setelah dipasang, tambahkan host di ⋯ → Host yang diizinkan. Selain itu tidak bisa.',
|
||||
'admin.plugins.noErrors': 'Tidak ada error yang tercatat.',
|
||||
'admin.plugins.uninstalled': 'Plugin dihapus',
|
||||
'admin.plugins.uninstallTitle': 'Hapus plugin?',
|
||||
@@ -406,6 +409,34 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.security.reviewedTitle': 'Apa arti "Ditinjau"',
|
||||
'admin.plugins.security.reviewedBody':
|
||||
'Plugin yang ditinjau telah dipindai secara manual dari malware oleh seorang maintainer TREK pada setiap versi — diperiksa untuk kode berbahaya, bukan untuk apakah plugin berfungsi dengan baik. Ini bukan janji bahwa plugin tidak berbahaya.',
|
||||
'admin.plugins.security.signedTitle': 'Apa arti "Ditandatangani"',
|
||||
'admin.plugins.security.signedBody':
|
||||
'Checksum yang diverifikasi TREK pada setiap pemasangan membuktikan bahwa file-nya persis seperti yang dijamin registri. Tanda tangan membuktikan hal yang berbeda: bahwa file itu datang dari pembuatnya, ditandatangani dengan kunci yang hanya dia miliki. Plugin yang ditandatangani punya keduanya. Plugin tanpa tanda tangan bukan berarti tidak aman — ia hanya membawa satu jaminan lebih sedikit, dan sebagian besar plugin di registri saat ini memang belum ditandatangani.',
|
||||
'admin.plugins.signed': 'Ditandatangani',
|
||||
'admin.plugins.signedHint': 'Diverifikasi dengan kunci tanda tangan pembuatnya saat dipasang',
|
||||
'admin.plugins.unsigned': 'Tanpa tanda tangan',
|
||||
'admin.plugins.unsignedHint':
|
||||
'File-nya cocok dengan yang dijamin registri, tetapi tidak ada yang mengaitkannya dengan pembuatnya. Satu jaminan lebih sedikit — bukan berarti tidak aman.',
|
||||
'admin.plugins.updateBlocked': 'Pembaruan diblokir — {reason}',
|
||||
'admin.plugins.reviewBlock': 'Tinjau',
|
||||
'admin.plugins.retrusted': 'Kunci tanda tangan baru dipercaya — plugin diperbarui',
|
||||
'admin.plugins.sig.title': 'Tanda tangan {name} tidak dapat diverifikasi',
|
||||
'admin.plugins.sig.keyChangedBody':
|
||||
'Kunci tanda tangan pembuatnya bukan kunci yang dipakai saat plugin ini dipasang. Pembuat memang kadang mengganti kunci — tetapi penyerang yang mengambil alih plugin ini akan terlihat persis seperti ini.',
|
||||
'admin.plugins.sig.invalidBody':
|
||||
'File-nya tidak cocok dengan tanda tangan pembuatnya. File itu bukan yang ditandatangani pembuatnya — entah rusak, entah diutak-atik. Ini tidak bisa dilewati.',
|
||||
'admin.plugins.sig.missingBody':
|
||||
'Plugin ini ditandatangani saat kamu memasangnya, tetapi versi barunya tidak menyertakan tanda tangan. TREK tidak akan diam-diam menerima kemunduran itu. Ini tidak bisa dilewati.',
|
||||
'admin.plugins.sig.incompleteBody':
|
||||
'Entri registrinya setengah ditandatangani: ia mendeklarasikan kunci pembuat tetapi versinya tidak membawa tanda tangan (atau sebaliknya). Itu kesalahan di sisi plugin. Ini tidak bisa dilewati.',
|
||||
'admin.plugins.sig.pinnedKey': 'Kunci saat dipasang',
|
||||
'admin.plugins.sig.newKey': 'Kunci yang ditawarkan sekarang',
|
||||
'admin.plugins.sig.confirmOutOfBand':
|
||||
'TREK tidak bisa membedakan pergantian kunci yang sah dari pengambilalihan — dari sini keduanya terlihat sama persis. Pastikan dulu kunci baru itu ke pembuatnya lewat saluran yang sudah kamu percaya sebelum kamu menerimanya. Setelah kamu menerimanya, plugin diperbarui dan kunci barunya diingat.',
|
||||
'admin.plugins.sig.retrustConfirm': 'Percayai kunci baru & perbarui',
|
||||
'admin.plugins.sig.cancel': 'Jangan percayai',
|
||||
'admin.plugins.sig.consentUnsigned':
|
||||
'Tidak ada yang mengaitkan versi ini dengan pembuatnya — file-nya cocok dengan registri, tetapi tidak membawa tanda tangan pembuat.',
|
||||
'admin.plugins.security.trustTitle': 'Intinya',
|
||||
'admin.plugins.security.trustBody':
|
||||
'Memasang plugin sama seperti memasang aplikasi pihak ketiga mana pun: hanya tambahkan kode dari pembuat yang kamu percaya, dan jika ragu, periksa sendiri terlebih dahulu. TREK tidak bertanggung jawab atas plugin pihak ketiga.',
|
||||
@@ -465,6 +496,10 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.dep.download': 'Unduh',
|
||||
'admin.plugins.dep.update': 'Perbarui',
|
||||
'admin.plugins.dep.resolveHint': 'Mengunduh versi kompatibel terbaru, termasuk dependensinya sendiri.',
|
||||
'admin.plugins.dep.trekIncompatible': 'Membutuhkan TREK {range} — server ini menjalankan {host}',
|
||||
'admin.plugins.dep.trekUnknown': 'Tidak menyatakan versi TREK mana yang didukung',
|
||||
'admin.plugins.installCompatible': 'Instal {version}',
|
||||
'admin.plugins.incompatible': 'Tidak kompatibel',
|
||||
'admin.plugins.accessTitle': 'Yang bisa diaksesnya',
|
||||
'admin.plugins.connectsTitle': 'Terhubung ke',
|
||||
'admin.plugins.detailsTitle': 'Detail',
|
||||
|
||||
@@ -187,14 +187,17 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.viewErrors': 'Visualizza log errori',
|
||||
'admin.plugins.errorLog': 'Log errori',
|
||||
'admin.plugins.allowedHosts': 'Host consentiti',
|
||||
'admin.plugins.allowedHosts.hint': 'Questo plugin comunica con un servizio che solo tu puoi indicare (un server self-hosted). Aggiungi gli host che può raggiungere — non ne raggiungerà altri.',
|
||||
'admin.plugins.allowedHosts.hint':
|
||||
'Questo plugin comunica con un servizio che solo tu puoi indicare (un server self-hosted). Aggiungi gli host che può raggiungere — non ne raggiungerà altri.',
|
||||
'admin.plugins.allowedHosts.none': 'Nessun host aggiunto.',
|
||||
'admin.plugins.allowedHosts.unsupported': 'Questo plugin non usa host forniti dall’operatore. I suoi host consentiti sono fissati nel manifest.',
|
||||
'admin.plugins.allowedHosts.unsupported':
|
||||
'Questo plugin non usa host forniti dall’operatore. I suoi host consentiti sono fissati nel manifest.',
|
||||
'admin.plugins.allowedHosts.restartNote': 'Il salvataggio riavvia il plugin perché prenda la nuova lista.',
|
||||
'admin.plugins.allowedHosts.add': 'Aggiungi host consentito',
|
||||
'admin.plugins.allowedHosts.count': '{n} host consentiti',
|
||||
'admin.plugins.operatorEgressPill': '+ host aggiunti da te',
|
||||
'admin.plugins.operatorEgressHint': 'Questo plugin comunica con un servizio che solo tu puoi indicare (un server self-hosted). Dopo l’installazione, aggiungi gli host che può raggiungere in ⋯ → Host consentiti. Non ne raggiungerà altri.',
|
||||
'admin.plugins.operatorEgressHint':
|
||||
'Questo plugin comunica con un servizio che solo tu puoi indicare (un server self-hosted). Dopo l’installazione, aggiungi gli host che può raggiungere in ⋯ → Host consentiti. Non ne raggiungerà altri.',
|
||||
'admin.plugins.noErrors': 'Nessun errore registrato.',
|
||||
'admin.plugins.uninstalled': 'Plugin disinstallato',
|
||||
'admin.plugins.uninstallTitle': 'Disinstallare il plugin?',
|
||||
@@ -359,6 +362,34 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.security.reviewedTitle': 'Cosa significa "Verificato"',
|
||||
'admin.plugins.security.reviewedBody':
|
||||
'Un plugin verificato è stato esaminato manualmente alla ricerca di malware da un manutentore di TREK a ogni versione — controllato per individuare codice ostile, non per valutare se funziona bene. Non è una promessa che il plugin sia innocuo.',
|
||||
'admin.plugins.security.signedTitle': 'Cosa significa "Firmato"',
|
||||
'admin.plugins.security.signedBody':
|
||||
"Il checksum che TREK verifica a ogni installazione dimostra che i file sono esattamente quelli garantiti dal registro. Una firma dimostra un'altra cosa: che provengono dall'autore, firmati con una chiave che solo lui possiede. Un plugin firmato ha entrambe le cose. Uno non firmato non è insicuro — porta semplicemente una garanzia in meno, e oggi la maggior parte dei plugin nel registro non è firmata.",
|
||||
'admin.plugins.signed': 'Firmato',
|
||||
'admin.plugins.signedHint': "Verificato con la chiave di firma dell'autore al momento dell'installazione",
|
||||
'admin.plugins.unsigned': 'Non firmato',
|
||||
'admin.plugins.unsignedHint':
|
||||
"I file corrispondono a quanto garantito dal registro, ma nulla li collega all'autore. Una garanzia in meno — non è insicuro.",
|
||||
'admin.plugins.updateBlocked': 'Aggiornamento bloccato — {reason}',
|
||||
'admin.plugins.reviewBlock': 'Esamina',
|
||||
'admin.plugins.retrusted': 'Nuova chiave di firma considerata attendibile — il plugin è aggiornato',
|
||||
'admin.plugins.sig.title': 'Impossibile verificare la firma di {name}',
|
||||
'admin.plugins.sig.keyChangedBody':
|
||||
"La chiave di firma dell'autore non è quella con cui questo plugin è stato installato. Gli autori a volte cambiano chiave — ma un aggressore che avesse preso il controllo del plugin apparirebbe esattamente così.",
|
||||
'admin.plugins.sig.invalidBody':
|
||||
"I file non corrispondono alla firma dell'autore. Non sono ciò che l'autore ha firmato — o si sono corrotti, o sono stati manomessi. Non è possibile forzare questo blocco.",
|
||||
'admin.plugins.sig.missingBody':
|
||||
"Questo plugin era firmato quando l'hai installato, ma la nuova versione non include alcuna firma. TREK non accetterà in silenzio questo passo indietro. Non è possibile forzare questo blocco.",
|
||||
'admin.plugins.sig.incompleteBody':
|
||||
"La voce del registro è firmata a metà: dichiara una chiave dell'autore ma la versione non porta alcuna firma (o viceversa). È un errore lato plugin. Non è possibile forzare questo blocco.",
|
||||
'admin.plugins.sig.pinnedKey': 'Chiave con cui è stato installato',
|
||||
'admin.plugins.sig.newKey': 'Chiave che propone ora',
|
||||
'admin.plugins.sig.confirmOutOfBand':
|
||||
"TREK non può distinguere una legittima rotazione della chiave da un'appropriazione — da qui appaiono identiche. Prima di accettarla, conferma la nuova chiave con l'autore attraverso un canale di cui ti fidi già. Una volta fatto, il plugin viene aggiornato e la nuova chiave viene memorizzata.",
|
||||
'admin.plugins.sig.retrustConfirm': 'Fidati della nuova chiave e aggiorna',
|
||||
'admin.plugins.sig.cancel': 'Non fidarti',
|
||||
'admin.plugins.sig.consentUnsigned':
|
||||
"Nulla collega questa versione al suo autore — i file corrispondono al registro, ma non portano alcuna firma dell'autore.",
|
||||
'admin.plugins.security.trustTitle': 'In conclusione',
|
||||
'admin.plugins.security.trustBody':
|
||||
'Installare un plugin è come installare una qualsiasi app di terze parti: aggiungi solo codice di autori di cui ti fidi e, in caso di dubbio, ispezionalo personalmente prima. TREK non si assume alcuna responsabilità per i plugin di terze parti.',
|
||||
@@ -418,6 +449,10 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.dep.download': 'Scarica',
|
||||
'admin.plugins.dep.update': 'Aggiorna',
|
||||
'admin.plugins.dep.resolveHint': 'Scarica la versione compatibile più recente, incluse le sue dipendenze.',
|
||||
'admin.plugins.dep.trekIncompatible': 'Richiede TREK {range} — questo server esegue {host}',
|
||||
'admin.plugins.dep.trekUnknown': 'Non indica quali versioni di TREK supporta',
|
||||
'admin.plugins.installCompatible': 'Installa {version}',
|
||||
'admin.plugins.incompatible': 'Non compatibile',
|
||||
'admin.plugins.accessTitle': 'A cosa può accedere',
|
||||
'admin.plugins.connectsTitle': 'Si connette a',
|
||||
'admin.plugins.detailsTitle': 'Dettagli',
|
||||
|
||||
@@ -296,7 +296,8 @@ const settings: TranslationStrings = {
|
||||
'settings.notificationPreferences.configure': 'Configura',
|
||||
'settings.notificationPreferences.testSuccess': 'Notifica di prova inviata.',
|
||||
'settings.notificationPreferences.testFailed': 'Test non riuscito.',
|
||||
'settings.notificationPreferences.pluginConfigured': 'Configurato. Gestisci le credenziali nelle impostazioni del plugin.',
|
||||
'settings.notificationPreferences.pluginConfigured':
|
||||
'Configurato. Gestisci le credenziali nelle impostazioni del plugin.',
|
||||
'settings.notificationPreferences.webhook': 'Webhook',
|
||||
'settings.notificationPreferences.email': 'Email',
|
||||
'settings.notificationPreferences.ntfy': 'Ntfy',
|
||||
|
||||
@@ -219,14 +219,17 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.viewErrors': 'エラーログを表示',
|
||||
'admin.plugins.errorLog': 'エラーログ',
|
||||
'admin.plugins.allowedHosts': '許可するホスト',
|
||||
'admin.plugins.allowedHosts.hint': 'このプラグインは、あなたしか指定できないサービス(セルフホストのサーバー)と通信します。到達を許可するホストを追加してください。それ以外には到達できません。',
|
||||
'admin.plugins.allowedHosts.hint':
|
||||
'このプラグインは、あなたしか指定できないサービス(セルフホストのサーバー)と通信します。到達を許可するホストを追加してください。それ以外には到達できません。',
|
||||
'admin.plugins.allowedHosts.none': 'ホストはまだ追加されていません。',
|
||||
'admin.plugins.allowedHosts.unsupported': 'このプラグインは運用者指定のホストを使いません。許可ホストはマニフェストで固定されています。',
|
||||
'admin.plugins.allowedHosts.unsupported':
|
||||
'このプラグインは運用者指定のホストを使いません。許可ホストはマニフェストで固定されています。',
|
||||
'admin.plugins.allowedHosts.restartNote': '保存するとプラグインが再起動し、新しい一覧が反映されます。',
|
||||
'admin.plugins.allowedHosts.add': '許可ホストを追加',
|
||||
'admin.plugins.allowedHosts.count': '許可ホスト {n} 件',
|
||||
'admin.plugins.operatorEgressPill': '+ 追加するホスト',
|
||||
'admin.plugins.operatorEgressHint': 'このプラグインは、あなたしか指定できないサービス(セルフホストのサーバー)と通信します。インストール後、⋯ → 許可するホスト から追加してください。それ以外には到達できません。',
|
||||
'admin.plugins.operatorEgressHint':
|
||||
'このプラグインは、あなたしか指定できないサービス(セルフホストのサーバー)と通信します。インストール後、⋯ → 許可するホスト から追加してください。それ以外には到達できません。',
|
||||
'admin.plugins.noErrors': '記録されたエラーはありません。',
|
||||
'admin.plugins.uninstalled': 'プラグインをアンインストールしました',
|
||||
'admin.plugins.uninstallTitle': 'プラグインをアンインストールしますか?',
|
||||
@@ -372,6 +375,34 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.security.reviewedTitle': '「レビュー済み」の意味',
|
||||
'admin.plugins.security.reviewedBody':
|
||||
'レビュー済みのプラグインは、TREKのメンテナーが各バージョンで手動でマルウェアの有無を確認したものです — 悪意のあるコードの有無を確認したものであり、正しく動作するかどうかを確認したものではありません。プラグインが無害であることを約束するものではありません。',
|
||||
'admin.plugins.security.signedTitle': '「署名済み」の意味',
|
||||
'admin.plugins.security.signedBody':
|
||||
'TREKがインストールのたびに検証するチェックサムは、ファイルがレジストリの保証するものとまったく同一であることを証明します。署名が証明するのは別のことです。そのファイルが作者本人から届いたこと、そして作者だけが持つ鍵で署名されていることです。署名済みのプラグインはその両方を備えています。署名がないプラグインが危険というわけではありません — 保証がひとつ少ないだけであり、現時点ではレジストリのプラグインの多くは署名されていません。',
|
||||
'admin.plugins.signed': '署名済み',
|
||||
'admin.plugins.signedHint': 'インストール時に作者の署名鍵で検証済み',
|
||||
'admin.plugins.unsigned': '署名なし',
|
||||
'admin.plugins.unsignedHint':
|
||||
'ファイルはレジストリが保証するものと一致しますが、作者と結びつけるものはありません。保証がひとつ少ないだけで、危険というわけではありません。',
|
||||
'admin.plugins.updateBlocked': 'アップデートをブロックしました — {reason}',
|
||||
'admin.plugins.reviewBlock': '確認する',
|
||||
'admin.plugins.retrusted': '新しい署名鍵を信頼しました — プラグインを更新しました',
|
||||
'admin.plugins.sig.title': '{name}の署名を検証できませんでした',
|
||||
'admin.plugins.sig.keyChangedBody':
|
||||
'作者の署名鍵が、このプラグインをインストールしたときの鍵と異なります。作者が鍵を更新することは実際にあります — ただし、プラグインを乗っ取った攻撃者も、ここからはまったく同じように見えます。',
|
||||
'admin.plugins.sig.invalidBody':
|
||||
'ファイルが作者の署名と一致しません。作者が署名したものではなく、破損したか、改ざんされたかのどちらかです。これを上書きすることはできません。',
|
||||
'admin.plugins.sig.missingBody':
|
||||
'このプラグインはインストール時には署名されていましたが、新しいバージョンには署名がありません。TREKはこの後退を黙って受け入れることはしません。これを上書きすることはできません。',
|
||||
'admin.plugins.sig.incompleteBody':
|
||||
'レジストリの登録内容が中途半端です。作者の鍵は宣言されているのに、このバージョンには署名がありません(またはその逆です)。これはプラグイン側の不備です。これを上書きすることはできません。',
|
||||
'admin.plugins.sig.pinnedKey': 'インストール時の鍵',
|
||||
'admin.plugins.sig.newKey': '現在提示されている鍵',
|
||||
'admin.plugins.sig.confirmOutOfBand':
|
||||
'TREKには、正当な鍵の更新と乗っ取りを見分けることはできません — ここからは両者はまったく同じに見えます。受け入れる前に、すでに信頼している経路で作者に新しい鍵を確認してください。受け入れると、プラグインが更新され、新しい鍵が記憶されます。',
|
||||
'admin.plugins.sig.retrustConfirm': '新しい鍵を信頼して更新',
|
||||
'admin.plugins.sig.cancel': '信頼しない',
|
||||
'admin.plugins.sig.consentUnsigned':
|
||||
'このバージョンと作者を結びつけるものはありません — ファイルはレジストリと一致しますが、作者の署名は付いていません。',
|
||||
'admin.plugins.security.trustTitle': '結論',
|
||||
'admin.plugins.security.trustBody':
|
||||
'プラグインのインストールは、サードパーティ製アプリのインストールと同じです。信頼できる作者のコードのみを追加し、少しでも疑わしい場合は、まずご自身で内容を確認してください。TREKはサードパーティ製プラグインについて一切の責任を負いません。',
|
||||
@@ -431,6 +462,10 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.dep.download': 'ダウンロード',
|
||||
'admin.plugins.dep.update': '更新',
|
||||
'admin.plugins.dep.resolveHint': '独自の依存関係を含め、最新の互換バージョンをダウンロードします。',
|
||||
'admin.plugins.dep.trekIncompatible': 'TREK {range} が必要です — このサーバーは {host} です',
|
||||
'admin.plugins.dep.trekUnknown': '対応する TREK バージョンが宣言されていません',
|
||||
'admin.plugins.installCompatible': '{version} をインストール',
|
||||
'admin.plugins.incompatible': '非対応',
|
||||
'admin.plugins.accessTitle': 'アクセスできるもの',
|
||||
'admin.plugins.connectsTitle': '接続先',
|
||||
'admin.plugins.detailsTitle': '詳細',
|
||||
|
||||
@@ -224,14 +224,17 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.viewErrors': '오류 로그 보기',
|
||||
'admin.plugins.errorLog': '오류 로그',
|
||||
'admin.plugins.allowedHosts': '허용된 호스트',
|
||||
'admin.plugins.allowedHosts.hint': '이 플러그인은 관리자만 알 수 있는 서비스(자체 호스팅 서버)와 통신합니다. 접근을 허용할 호스트를 추가하세요 — 그 외에는 접근할 수 없습니다.',
|
||||
'admin.plugins.allowedHosts.hint':
|
||||
'이 플러그인은 관리자만 알 수 있는 서비스(자체 호스팅 서버)와 통신합니다. 접근을 허용할 호스트를 추가하세요 — 그 외에는 접근할 수 없습니다.',
|
||||
'admin.plugins.allowedHosts.none': '아직 추가된 호스트가 없습니다.',
|
||||
'admin.plugins.allowedHosts.unsupported': '이 플러그인은 운영자가 지정하는 호스트를 사용하지 않습니다. 허용 호스트는 매니페스트에 고정되어 있습니다.',
|
||||
'admin.plugins.allowedHosts.unsupported':
|
||||
'이 플러그인은 운영자가 지정하는 호스트를 사용하지 않습니다. 허용 호스트는 매니페스트에 고정되어 있습니다.',
|
||||
'admin.plugins.allowedHosts.restartNote': '저장하면 플러그인이 재시작되어 새 목록을 적용합니다.',
|
||||
'admin.plugins.allowedHosts.add': '허용 호스트 추가',
|
||||
'admin.plugins.allowedHosts.count': '허용 호스트 {n}개',
|
||||
'admin.plugins.operatorEgressPill': '+ 직접 추가하는 호스트',
|
||||
'admin.plugins.operatorEgressHint': '이 플러그인은 관리자만 알 수 있는 서비스(자체 호스팅 서버)와 통신합니다. 설치 후 ⋯ → 허용된 호스트에서 추가하세요. 그 외에는 접근할 수 없습니다.',
|
||||
'admin.plugins.operatorEgressHint':
|
||||
'이 플러그인은 관리자만 알 수 있는 서비스(자체 호스팅 서버)와 통신합니다. 설치 후 ⋯ → 허용된 호스트에서 추가하세요. 그 외에는 접근할 수 없습니다.',
|
||||
'admin.plugins.noErrors': '기록된 오류가 없습니다.',
|
||||
'admin.plugins.uninstalled': '플러그인이 제거되었습니다',
|
||||
'admin.plugins.uninstallTitle': '플러그인을 제거할까요?',
|
||||
@@ -373,6 +376,34 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.security.reviewedTitle': '"검토됨"의 의미',
|
||||
'admin.plugins.security.reviewedBody':
|
||||
'검토된 플러그인은 각 버전마다 TREK 관리자가 악성코드를 수동으로 검사한 것입니다 — 정상 작동 여부가 아니라 악의적인 코드가 있는지 확인한 것입니다. 플러그인이 무해하다는 약속은 아닙니다.',
|
||||
'admin.plugins.security.signedTitle': '"서명됨"의 의미',
|
||||
'admin.plugins.security.signedBody':
|
||||
'TREK이 설치할 때마다 검증하는 체크섬은 파일이 레지스트리가 보증하는 것과 정확히 일치함을 증명합니다. 서명은 다른 것을 증명합니다. 그 파일이 작성자에게서 왔으며, 작성자만 가진 키로 서명되었다는 사실입니다. 서명된 플러그인은 두 가지를 모두 갖추고 있습니다. 서명되지 않았다고 해서 안전하지 않다는 뜻은 아닙니다 — 보장이 하나 적을 뿐이며, 현재 레지스트리의 플러그인 대부분은 서명되어 있지 않습니다.',
|
||||
'admin.plugins.signed': '서명됨',
|
||||
'admin.plugins.signedHint': '설치 당시 작성자의 서명 키로 검증되었습니다',
|
||||
'admin.plugins.unsigned': '서명 없음',
|
||||
'admin.plugins.unsignedHint':
|
||||
'파일은 레지스트리가 보증하는 것과 일치하지만, 작성자와 연결해 주는 것은 없습니다. 보장이 하나 적을 뿐, 안전하지 않다는 뜻은 아닙니다.',
|
||||
'admin.plugins.updateBlocked': '업데이트가 차단되었습니다 — {reason}',
|
||||
'admin.plugins.reviewBlock': '확인',
|
||||
'admin.plugins.retrusted': '새 서명 키를 신뢰했습니다 — 플러그인이 업데이트되었습니다',
|
||||
'admin.plugins.sig.title': '{name}의 서명을 검증할 수 없습니다',
|
||||
'admin.plugins.sig.keyChangedBody':
|
||||
'작성자의 서명 키가 이 플러그인을 설치할 때 사용된 키와 다릅니다. 작성자가 키를 교체하는 일은 실제로 있습니다 — 하지만 플러그인을 탈취한 공격자도 여기서는 이와 똑같아 보입니다.',
|
||||
'admin.plugins.sig.invalidBody':
|
||||
'파일이 작성자의 서명과 일치하지 않습니다. 작성자가 서명한 파일이 아니며, 손상되었거나 변조된 것입니다. 이 경우는 무시하고 진행할 수 없습니다.',
|
||||
'admin.plugins.sig.missingBody':
|
||||
'설치할 때 이 플러그인에는 서명이 있었지만, 새 버전에는 서명이 없습니다. TREK은 이런 후퇴를 조용히 받아들이지 않습니다. 이 경우는 무시하고 진행할 수 없습니다.',
|
||||
'admin.plugins.sig.incompleteBody':
|
||||
'레지스트리 항목이 반만 서명되어 있습니다. 작성자 키는 선언되어 있지만 해당 버전에는 서명이 없습니다(또는 그 반대입니다). 이는 플러그인 쪽의 실수입니다. 이 경우는 무시하고 진행할 수 없습니다.',
|
||||
'admin.plugins.sig.pinnedKey': '설치할 때 사용된 키',
|
||||
'admin.plugins.sig.newKey': '지금 제시하는 키',
|
||||
'admin.plugins.sig.confirmOutOfBand':
|
||||
'TREK은 정당한 키 교체와 탈취를 구분할 수 없습니다 — 여기서는 둘이 똑같아 보입니다. 받아들이기 전에 이미 신뢰하는 경로를 통해 작성자에게 새 키를 확인하세요. 받아들이면 플러그인이 업데이트되고 새 키가 기억됩니다.',
|
||||
'admin.plugins.sig.retrustConfirm': '새 키를 신뢰하고 업데이트',
|
||||
'admin.plugins.sig.cancel': '신뢰하지 않기',
|
||||
'admin.plugins.sig.consentUnsigned':
|
||||
'이 버전과 작성자를 연결해 주는 것이 없습니다 — 파일은 레지스트리와 일치하지만 작성자 서명이 없습니다.',
|
||||
'admin.plugins.security.trustTitle': '핵심 요약',
|
||||
'admin.plugins.security.trustBody':
|
||||
'플러그인을 설치하는 것은 다른 서드파티 앱을 설치하는 것과 같습니다: 신뢰할 수 있는 작성자의 코드만 추가하고, 의심스러우면 먼저 직접 확인하세요. TREK은 서드파티 플러그인에 대해 어떠한 책임도 지지 않습니다.',
|
||||
@@ -432,6 +463,10 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.dep.download': '다운로드',
|
||||
'admin.plugins.dep.update': '업데이트',
|
||||
'admin.plugins.dep.resolveHint': '자체 종속성을 포함하여 호환되는 최신 버전을 다운로드합니다.',
|
||||
'admin.plugins.dep.trekIncompatible': 'TREK {range}이(가) 필요합니다 — 이 서버는 {host}입니다',
|
||||
'admin.plugins.dep.trekUnknown': '지원하는 TREK 버전을 명시하지 않았습니다',
|
||||
'admin.plugins.installCompatible': '{version} 설치',
|
||||
'admin.plugins.incompatible': '호환되지 않음',
|
||||
'admin.plugins.accessTitle': '접근할 수 있는 항목',
|
||||
'admin.plugins.connectsTitle': '연결 대상',
|
||||
'admin.plugins.detailsTitle': '상세 정보',
|
||||
|
||||
@@ -188,14 +188,17 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.viewErrors': 'Foutenlog bekijken',
|
||||
'admin.plugins.errorLog': 'Foutenlog',
|
||||
'admin.plugins.allowedHosts': 'Toegestane hosts',
|
||||
'admin.plugins.allowedHosts.hint': 'Deze plug-in praat met een dienst die alleen jij kunt benoemen (een zelf-gehoste server). Voeg de hosts toe die hij mag bereiken — andere bereikt hij niet.',
|
||||
'admin.plugins.allowedHosts.hint':
|
||||
'Deze plug-in praat met een dienst die alleen jij kunt benoemen (een zelf-gehoste server). Voeg de hosts toe die hij mag bereiken — andere bereikt hij niet.',
|
||||
'admin.plugins.allowedHosts.none': 'Nog geen hosts toegevoegd.',
|
||||
'admin.plugins.allowedHosts.unsupported': 'Deze plug-in gebruikt geen door de beheerder opgegeven hosts. De toegestane hosts staan vast in het manifest.',
|
||||
'admin.plugins.allowedHosts.unsupported':
|
||||
'Deze plug-in gebruikt geen door de beheerder opgegeven hosts. De toegestane hosts staan vast in het manifest.',
|
||||
'admin.plugins.allowedHosts.restartNote': 'Opslaan herstart de plug-in zodat de nieuwe lijst wordt opgepakt.',
|
||||
'admin.plugins.allowedHosts.add': 'Toegestane host toevoegen',
|
||||
'admin.plugins.allowedHosts.count': '{n} toegestane host(s)',
|
||||
'admin.plugins.operatorEgressPill': '+ hosts die jij toevoegt',
|
||||
'admin.plugins.operatorEgressHint': 'Deze plug-in praat met een dienst die alleen jij kunt benoemen (een zelf-gehoste server). Voeg na installatie de bereikbare hosts toe via ⋯ → Toegestane hosts. Andere bereikt hij niet.',
|
||||
'admin.plugins.operatorEgressHint':
|
||||
'Deze plug-in praat met een dienst die alleen jij kunt benoemen (een zelf-gehoste server). Voeg na installatie de bereikbare hosts toe via ⋯ → Toegestane hosts. Andere bereikt hij niet.',
|
||||
'admin.plugins.noErrors': 'Geen fouten geregistreerd.',
|
||||
'admin.plugins.uninstalled': 'Plugin verwijderd',
|
||||
'admin.plugins.uninstallTitle': 'Plugin verwijderen?',
|
||||
@@ -359,6 +362,34 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.security.reviewedTitle': 'Wat "Beoordeeld" betekent',
|
||||
'admin.plugins.security.reviewedBody':
|
||||
'Een beoordeelde plugin is bij elke versie handmatig door een TREK-beheerder op malware gecontroleerd — gecontroleerd op kwaadaardige code, niet op de vraag of hij goed werkt. Het is geen belofte dat de plugin ongevaarlijk is.',
|
||||
'admin.plugins.security.signedTitle': 'Wat "Ondertekend" betekent',
|
||||
'admin.plugins.security.signedBody':
|
||||
'De controlesom die TREK bij elke installatie verifieert, bewijst dat de bestanden precies zijn waarvoor de registry instaat. Een handtekening bewijst iets anders: dat ze van de auteur komen, ondertekend met een sleutel die alleen hij heeft. Een ondertekende plugin heeft beide. Een niet-ondertekende plugin is niet onveilig — hij draagt simpelweg één garantie minder, en de meeste plugins in de registry zijn vandaag niet ondertekend.',
|
||||
'admin.plugins.signed': 'Ondertekend',
|
||||
'admin.plugins.signedHint': 'Bij de installatie geverifieerd tegen de ondertekeningssleutel van de auteur',
|
||||
'admin.plugins.unsigned': 'Niet ondertekend',
|
||||
'admin.plugins.unsignedHint':
|
||||
'De bestanden komen overeen met waarvoor de registry instaat, maar niets koppelt ze aan de auteur. Eén garantie minder — niet onveilig.',
|
||||
'admin.plugins.updateBlocked': 'Update geblokkeerd — {reason}',
|
||||
'admin.plugins.reviewBlock': 'Bekijken',
|
||||
'admin.plugins.retrusted': 'Nieuwe ondertekeningssleutel vertrouwd — de plugin is bijgewerkt',
|
||||
'admin.plugins.sig.title': 'De handtekening van {name} kon niet worden geverifieerd',
|
||||
'admin.plugins.sig.keyChangedBody':
|
||||
'De ondertekeningssleutel van de auteur is niet die waarmee deze plugin is geïnstalleerd. Auteurs wisselen weleens van sleutel — maar een aanvaller die de plugin heeft overgenomen, zou er precies zo uitzien.',
|
||||
'admin.plugins.sig.invalidBody':
|
||||
'De bestanden komen niet overeen met de handtekening van de auteur. Ze zijn niet wat de auteur heeft ondertekend — ze zijn beschadigd geraakt, of er is mee geknoeid. Dit kan niet worden genegeerd.',
|
||||
'admin.plugins.sig.missingBody':
|
||||
'Deze plugin was ondertekend toen je hem installeerde, maar de nieuwe versie levert geen handtekening mee. TREK aanvaardt die stap terug niet stilzwijgend. Dit kan niet worden genegeerd.',
|
||||
'admin.plugins.sig.incompleteBody':
|
||||
'Het registry-item is half ondertekend: het vermeldt een auteurssleutel, maar de versie draagt geen handtekening (of andersom). Dat is een fout aan de kant van de plugin. Dit kan niet worden genegeerd.',
|
||||
'admin.plugins.sig.pinnedKey': 'Sleutel waarmee hij is geïnstalleerd',
|
||||
'admin.plugins.sig.newKey': 'Sleutel die hij nu aanbiedt',
|
||||
'admin.plugins.sig.confirmOutOfBand':
|
||||
'TREK kan een legitieme sleutelwissel niet onderscheiden van een overname — van hieruit zien ze er identiek uit. Bevestig de nieuwe sleutel bij de auteur via een kanaal dat je al vertrouwt voordat je hem accepteert. Zodra je dat doet, wordt de plugin bijgewerkt en wordt de nieuwe sleutel onthouden.',
|
||||
'admin.plugins.sig.retrustConfirm': 'Nieuwe sleutel vertrouwen en bijwerken',
|
||||
'admin.plugins.sig.cancel': 'Niet vertrouwen',
|
||||
'admin.plugins.sig.consentUnsigned':
|
||||
'Niets koppelt deze versie aan de auteur — de bestanden komen overeen met de registry, maar dragen geen handtekening van de auteur.',
|
||||
'admin.plugins.security.trustTitle': 'De conclusie',
|
||||
'admin.plugins.security.trustBody':
|
||||
'Een plugin installeren is als het installeren van elke andere app van derden: voeg alleen code toe van auteurs die je vertrouwt en controleer deze bij twijfel eerst zelf. TREK aanvaardt geen enkele verantwoordelijkheid voor plugins van derden.',
|
||||
@@ -418,6 +449,10 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.dep.download': 'Downloaden',
|
||||
'admin.plugins.dep.update': 'Bijwerken',
|
||||
'admin.plugins.dep.resolveHint': 'Downloadt de nieuwste compatibele versie, inclusief de eigen afhankelijkheden.',
|
||||
'admin.plugins.dep.trekIncompatible': 'Vereist TREK {range} — deze server draait {host}',
|
||||
'admin.plugins.dep.trekUnknown': 'Geeft niet aan welke TREK-versies worden ondersteund',
|
||||
'admin.plugins.installCompatible': '{version} installeren',
|
||||
'admin.plugins.incompatible': 'Niet compatibel',
|
||||
'admin.plugins.accessTitle': 'Waartoe hij toegang heeft',
|
||||
'admin.plugins.connectsTitle': 'Maakt verbinding met',
|
||||
'admin.plugins.detailsTitle': 'Details',
|
||||
|
||||
@@ -297,7 +297,8 @@ const settings: TranslationStrings = {
|
||||
'settings.notificationPreferences.configure': 'Instellen',
|
||||
'settings.notificationPreferences.testSuccess': 'Testmelding verstuurd.',
|
||||
'settings.notificationPreferences.testFailed': 'Test mislukt.',
|
||||
'settings.notificationPreferences.pluginConfigured': 'Ingesteld. Beheer je gegevens op de instellingenpagina van de plug-in.',
|
||||
'settings.notificationPreferences.pluginConfigured':
|
||||
'Ingesteld. Beheer je gegevens op de instellingenpagina van de plug-in.',
|
||||
'settings.notificationPreferences.webhook': 'Webhook',
|
||||
'settings.notificationPreferences.email': 'Email',
|
||||
'settings.notificationPreferences.ntfy': 'Ntfy',
|
||||
|
||||
@@ -181,14 +181,17 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.viewErrors': 'Pokaż dziennik błędów',
|
||||
'admin.plugins.errorLog': 'Dziennik błędów',
|
||||
'admin.plugins.allowedHosts': 'Dozwolone hosty',
|
||||
'admin.plugins.allowedHosts.hint': 'Ta wtyczka łączy się z usługą, którą tylko Ty możesz wskazać (serwer self-hosted). Dodaj hosty, do których może sięgać — do innych nie sięgnie.',
|
||||
'admin.plugins.allowedHosts.hint':
|
||||
'Ta wtyczka łączy się z usługą, którą tylko Ty możesz wskazać (serwer self-hosted). Dodaj hosty, do których może sięgać — do innych nie sięgnie.',
|
||||
'admin.plugins.allowedHosts.none': 'Nie dodano jeszcze żadnych hostów.',
|
||||
'admin.plugins.allowedHosts.unsupported': 'Ta wtyczka nie używa hostów podanych przez operatora. Jej dozwolone hosty są zapisane w manifeście.',
|
||||
'admin.plugins.allowedHosts.unsupported':
|
||||
'Ta wtyczka nie używa hostów podanych przez operatora. Jej dozwolone hosty są zapisane w manifeście.',
|
||||
'admin.plugins.allowedHosts.restartNote': 'Zapis restartuje wtyczkę, aby pobrała nową listę.',
|
||||
'admin.plugins.allowedHosts.add': 'Dodaj dozwolony host',
|
||||
'admin.plugins.allowedHosts.count': '{n} dozwolonych hostów',
|
||||
'admin.plugins.operatorEgressPill': '+ hosty, które dodasz',
|
||||
'admin.plugins.operatorEgressHint': 'Ta wtyczka łączy się z usługą, którą tylko Ty możesz wskazać (serwer self-hosted). Po instalacji dodaj hosty w ⋯ → Dozwolone hosty. Do innych nie sięgnie.',
|
||||
'admin.plugins.operatorEgressHint':
|
||||
'Ta wtyczka łączy się z usługą, którą tylko Ty możesz wskazać (serwer self-hosted). Po instalacji dodaj hosty w ⋯ → Dozwolone hosty. Do innych nie sięgnie.',
|
||||
'admin.plugins.noErrors': 'Brak zarejestrowanych błędów.',
|
||||
'admin.plugins.uninstalled': 'Wtyczka została odinstalowana',
|
||||
'admin.plugins.uninstallTitle': 'Odinstalować wtyczkę?',
|
||||
@@ -356,6 +359,34 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.security.reviewedTitle': 'Co oznacza „Zweryfikowana”',
|
||||
'admin.plugins.security.reviewedBody':
|
||||
'Zweryfikowana wtyczka została ręcznie przeskanowana pod kątem złośliwego oprogramowania przez opiekuna TREK w każdej wersji — sprawdzona pod kątem wrogiego kodu, a nie tego, czy działa dobrze. To nie jest obietnica, że wtyczka jest nieszkodliwa.',
|
||||
'admin.plugins.security.signedTitle': 'Co oznacza „Podpisana”',
|
||||
'admin.plugins.security.signedBody':
|
||||
'Suma kontrolna, którą TREK weryfikuje przy każdej instalacji, dowodzi, że pliki są dokładnie tym, za co ręczy rejestr. Podpis dowodzi czegoś innego: że pochodzą od autora i zostały podpisane kluczem, który ma tylko on. Podpisana wtyczka ma jedno i drugie. Wtyczka bez podpisu nie jest niebezpieczna — po prostu niesie o jedną gwarancję mniej, a większość wtyczek w rejestrze jest dziś niepodpisana.',
|
||||
'admin.plugins.signed': 'Podpisana',
|
||||
'admin.plugins.signedHint': 'Zweryfikowana kluczem podpisującym autora w chwili instalacji',
|
||||
'admin.plugins.unsigned': 'Niepodpisana',
|
||||
'admin.plugins.unsignedHint':
|
||||
'Pliki zgadzają się z tym, za co ręczy rejestr, ale nic nie wiąże ich z autorem. O jedną gwarancję mniej — nie oznacza to, że jest niebezpieczna.',
|
||||
'admin.plugins.updateBlocked': 'Aktualizacja zablokowana — {reason}',
|
||||
'admin.plugins.reviewBlock': 'Sprawdź',
|
||||
'admin.plugins.retrusted': 'Nowy klucz podpisujący uznany za zaufany — wtyczka została zaktualizowana',
|
||||
'admin.plugins.sig.title': 'Nie udało się zweryfikować podpisu wtyczki {name}',
|
||||
'admin.plugins.sig.keyChangedBody':
|
||||
'Klucz podpisujący autora nie jest tym, którym podpisano wtyczkę w chwili instalacji. Autorzy rzeczywiście wymieniają klucze — ale atakujący, który przejął wtyczkę, wyglądałby dokładnie tak samo.',
|
||||
'admin.plugins.sig.invalidBody':
|
||||
'Pliki nie zgadzają się z podpisem autora. Nie są tym, co autor podpisał — albo uległy uszkodzeniu, albo zostały zmodyfikowane. Tego nie można pominąć.',
|
||||
'admin.plugins.sig.missingBody':
|
||||
'Ta wtyczka była podpisana, gdy ją instalowałeś, ale nowa wersja nie zawiera żadnego podpisu. TREK nie przyjmie po cichu takiego kroku wstecz. Tego nie można pominąć.',
|
||||
'admin.plugins.sig.incompleteBody':
|
||||
'Wpis w rejestrze jest podpisany połowicznie: deklaruje klucz autora, ale wersja nie ma podpisu (albo odwrotnie). To błąd po stronie wtyczki. Tego nie można pominąć.',
|
||||
'admin.plugins.sig.pinnedKey': 'Klucz, którym podpisano ją przy instalacji',
|
||||
'admin.plugins.sig.newKey': 'Klucz, który proponuje teraz',
|
||||
'admin.plugins.sig.confirmOutOfBand':
|
||||
'TREK nie odróżni prawidłowej wymiany klucza od przejęcia wtyczki — stąd wyglądają identycznie. Zanim zaakceptujesz nowy klucz, potwierdź go u autora kanałem, któremu już ufasz. Gdy to zrobisz, wtyczka zostanie zaktualizowana, a nowy klucz zapamiętany.',
|
||||
'admin.plugins.sig.retrustConfirm': 'Zaufaj nowemu kluczowi i zaktualizuj',
|
||||
'admin.plugins.sig.cancel': 'Nie ufaj mu',
|
||||
'admin.plugins.sig.consentUnsigned':
|
||||
'Nic nie wiąże tej wersji z jej autorem — pliki zgadzają się z rejestrem, ale nie niosą podpisu autora.',
|
||||
'admin.plugins.security.trustTitle': 'Podsumowanie',
|
||||
'admin.plugins.security.trustBody':
|
||||
'Instalacja wtyczki jest jak instalacja dowolnej aplikacji innych firm: dodawaj tylko kod od autorów, którym ufasz, a w razie wątpliwości najpierw samodzielnie go sprawdź. TREK nie ponosi żadnej odpowiedzialności za wtyczki innych firm.',
|
||||
@@ -415,6 +446,10 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.dep.download': 'Pobierz',
|
||||
'admin.plugins.dep.update': 'Aktualizuj',
|
||||
'admin.plugins.dep.resolveHint': 'Pobiera najnowszą zgodną wersję wraz z jej zależnościami.',
|
||||
'admin.plugins.dep.trekIncompatible': 'Wymaga TREK {range} — ten serwer działa na {host}',
|
||||
'admin.plugins.dep.trekUnknown': 'Nie określa, które wersje TREK obsługuje',
|
||||
'admin.plugins.installCompatible': 'Zainstaluj {version}',
|
||||
'admin.plugins.incompatible': 'Niezgodny',
|
||||
'admin.plugins.accessTitle': 'Do czego ma dostęp',
|
||||
'admin.plugins.connectsTitle': 'Łączy się z',
|
||||
'admin.plugins.detailsTitle': 'Szczegóły',
|
||||
|
||||
@@ -291,7 +291,8 @@ const settings: TranslationStrings = {
|
||||
'settings.notificationPreferences.configure': 'Skonfiguruj',
|
||||
'settings.notificationPreferences.testSuccess': 'Wysłano powiadomienie testowe.',
|
||||
'settings.notificationPreferences.testFailed': 'Test nie powiódł się.',
|
||||
'settings.notificationPreferences.pluginConfigured': 'Skonfigurowano. Dane logowania zmienisz na stronie ustawień wtyczki.',
|
||||
'settings.notificationPreferences.pluginConfigured':
|
||||
'Skonfigurowano. Dane logowania zmienisz na stronie ustawień wtyczki.',
|
||||
'settings.notificationPreferences.webhook': 'Webhook',
|
||||
'settings.notificationPreferences.email': 'Email',
|
||||
'settings.notificationPreferences.ntfy': 'Ntfy',
|
||||
|
||||
@@ -188,14 +188,17 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.viewErrors': 'Показать журнал ошибок',
|
||||
'admin.plugins.errorLog': 'Журнал ошибок',
|
||||
'admin.plugins.allowedHosts': 'Разрешённые хосты',
|
||||
'admin.plugins.allowedHosts.hint': 'Этот плагин обращается к сервису, который можете указать только вы (self-hosted сервер). Добавьте хосты, к которым он может обращаться — к другим он не сможет.',
|
||||
'admin.plugins.allowedHosts.hint':
|
||||
'Этот плагин обращается к сервису, который можете указать только вы (self-hosted сервер). Добавьте хосты, к которым он может обращаться — к другим он не сможет.',
|
||||
'admin.plugins.allowedHosts.none': 'Хосты пока не добавлены.',
|
||||
'admin.plugins.allowedHosts.unsupported': 'Этот плагин не использует хосты, задаваемые оператором. Его разрешённые хосты зафиксированы в манифесте.',
|
||||
'admin.plugins.allowedHosts.unsupported':
|
||||
'Этот плагин не использует хосты, задаваемые оператором. Его разрешённые хосты зафиксированы в манифесте.',
|
||||
'admin.plugins.allowedHosts.restartNote': 'Сохранение перезапускает плагин, чтобы он подхватил новый список.',
|
||||
'admin.plugins.allowedHosts.add': 'Добавить разрешённый хост',
|
||||
'admin.plugins.allowedHosts.count': 'Разрешённых хостов: {n}',
|
||||
'admin.plugins.operatorEgressPill': '+ добавленные вами хосты',
|
||||
'admin.plugins.operatorEgressHint': 'Этот плагин обращается к сервису, который можете указать только вы (self-hosted сервер). После установки добавьте хосты в ⋯ → Разрешённые хосты. К другим он не сможет обратиться.',
|
||||
'admin.plugins.operatorEgressHint':
|
||||
'Этот плагин обращается к сервису, который можете указать только вы (self-hosted сервер). После установки добавьте хосты в ⋯ → Разрешённые хосты. К другим он не сможет обратиться.',
|
||||
'admin.plugins.noErrors': 'Ошибок не зафиксировано.',
|
||||
'admin.plugins.uninstalled': 'Плагин удалён',
|
||||
'admin.plugins.uninstallTitle': 'Удалить плагин?',
|
||||
@@ -360,6 +363,34 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.security.reviewedTitle': 'Что означает «Проверен»',
|
||||
'admin.plugins.security.reviewedBody':
|
||||
'Проверенный плагин был вручную просканирован на вредоносный код сопровождающим TREK в каждой версии — проверен на вредоносный код, а не на то, насколько хорошо он работает. Это не обещание того, что плагин безвреден.',
|
||||
'admin.plugins.security.signedTitle': 'Что означает «Подписан»',
|
||||
'admin.plugins.security.signedBody':
|
||||
'Контрольная сумма, которую TREK проверяет при каждой установке, доказывает, что файлы — ровно те, за которые ручается реестр. Подпись доказывает другое: что они пришли от автора и подписаны ключом, который есть только у него. У подписанного плагина есть и то, и другое. Неподписанный плагин не является небезопасным — просто у него одной гарантией меньше, и большинство плагинов в реестре сегодня не подписаны.',
|
||||
'admin.plugins.signed': 'Подписан',
|
||||
'admin.plugins.signedHint': 'При установке проверен по ключу подписи автора',
|
||||
'admin.plugins.unsigned': 'Без подписи',
|
||||
'admin.plugins.unsignedHint':
|
||||
'Файлы совпадают с тем, за что ручается реестр, но ничто не связывает их с автором. Одной гарантией меньше — это не значит, что он небезопасен.',
|
||||
'admin.plugins.updateBlocked': 'Обновление заблокировано — {reason}',
|
||||
'admin.plugins.reviewBlock': 'Разобраться',
|
||||
'admin.plugins.retrusted': 'Новый ключ подписи принят — плагин обновлён',
|
||||
'admin.plugins.sig.title': 'Не удалось проверить подпись плагина «{name}»',
|
||||
'admin.plugins.sig.keyChangedBody':
|
||||
'Ключ подписи автора — не тот, с которым этот плагин был установлен. Авторы действительно меняют ключи, но злоумышленник, захвативший плагин, выглядел бы ровно так же.',
|
||||
'admin.plugins.sig.invalidBody':
|
||||
'Файлы не соответствуют подписи автора. Это не то, что автор подписал: они либо повреждены, либо изменены. Это нельзя обойти.',
|
||||
'admin.plugins.sig.missingBody':
|
||||
'Этот плагин был подписан, когда вы его устанавливали, но новая версия поставляется без подписи. TREK не примет такой откат молча. Это нельзя обойти.',
|
||||
'admin.plugins.sig.incompleteBody':
|
||||
'Запись в реестре подписана наполовину: в ней объявлен ключ автора, но у версии нет подписи (или наоборот). Это ошибка на стороне плагина. Это нельзя обойти.',
|
||||
'admin.plugins.sig.pinnedKey': 'Ключ, с которым он был установлен',
|
||||
'admin.plugins.sig.newKey': 'Ключ, который он предлагает сейчас',
|
||||
'admin.plugins.sig.confirmOutOfBand':
|
||||
'TREK не может отличить законную смену ключа от захвата — отсюда они выглядят одинаково. Прежде чем принять новый ключ, подтвердите его у автора по каналу, которому вы уже доверяете. После этого плагин будет обновлён, а новый ключ — запомнен.',
|
||||
'admin.plugins.sig.retrustConfirm': 'Доверять новому ключу и обновить',
|
||||
'admin.plugins.sig.cancel': 'Не доверять',
|
||||
'admin.plugins.sig.consentUnsigned':
|
||||
'Ничто не связывает эту версию с её автором — файлы совпадают с реестром, но не несут подписи автора.',
|
||||
'admin.plugins.security.trustTitle': 'Итог',
|
||||
'admin.plugins.security.trustBody':
|
||||
'Установка плагина похожа на установку любого стороннего приложения: добавляйте только код от авторов, которым доверяете, а при сомнениях сначала проверьте его самостоятельно. TREK не несёт ответственности за сторонние плагины.',
|
||||
@@ -419,6 +450,10 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.dep.download': 'Загрузить',
|
||||
'admin.plugins.dep.update': 'Обновить',
|
||||
'admin.plugins.dep.resolveHint': 'Загружает последнюю совместимую версию вместе с её зависимостями.',
|
||||
'admin.plugins.dep.trekIncompatible': 'Требуется TREK {range} — на этом сервере {host}',
|
||||
'admin.plugins.dep.trekUnknown': 'Не указано, какие версии TREK поддерживаются',
|
||||
'admin.plugins.installCompatible': 'Установить {version}',
|
||||
'admin.plugins.incompatible': 'Несовместимо',
|
||||
'admin.plugins.accessTitle': 'К чему есть доступ',
|
||||
'admin.plugins.connectsTitle': 'Подключается к',
|
||||
'admin.plugins.detailsTitle': 'Подробности',
|
||||
|
||||
@@ -244,14 +244,17 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.viewErrors': 'Visa felloggen',
|
||||
'admin.plugins.errorLog': 'Fellogg',
|
||||
'admin.plugins.allowedHosts': 'Tillåtna värdar',
|
||||
'admin.plugins.allowedHosts.hint': 'Detta plugin pratar med en tjänst som bara du kan namnge (en självhostad server). Lägg till de värdar det får nå — inga andra.',
|
||||
'admin.plugins.allowedHosts.hint':
|
||||
'Detta plugin pratar med en tjänst som bara du kan namnge (en självhostad server). Lägg till de värdar det får nå — inga andra.',
|
||||
'admin.plugins.allowedHosts.none': 'Inga värdar tillagda ännu.',
|
||||
'admin.plugins.allowedHosts.unsupported': 'Detta plugin använder inte operatörsangivna värdar. Dess tillåtna värdar är låsta i manifestet.',
|
||||
'admin.plugins.allowedHosts.unsupported':
|
||||
'Detta plugin använder inte operatörsangivna värdar. Dess tillåtna värdar är låsta i manifestet.',
|
||||
'admin.plugins.allowedHosts.restartNote': 'Att spara startar om pluginet så att den nya listan används.',
|
||||
'admin.plugins.allowedHosts.add': 'Lägg till tillåten värd',
|
||||
'admin.plugins.allowedHosts.count': '{n} tillåtna värdar',
|
||||
'admin.plugins.operatorEgressPill': '+ värdar du lägger till',
|
||||
'admin.plugins.operatorEgressHint': 'Detta plugin pratar med en tjänst som bara du kan namnge (en självhostad server). Lägg efter installation till värdarna under ⋯ → Tillåtna värdar. Inga andra nås.',
|
||||
'admin.plugins.operatorEgressHint':
|
||||
'Detta plugin pratar med en tjänst som bara du kan namnge (en självhostad server). Lägg efter installation till värdarna under ⋯ → Tillåtna värdar. Inga andra nås.',
|
||||
'admin.plugins.noErrors': 'Inga fel loggade.',
|
||||
'admin.plugins.uninstalled': 'Pluginet har avinstallerats',
|
||||
'admin.plugins.uninstallTitle': 'Avinstallera pluginet?',
|
||||
@@ -414,6 +417,34 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.security.reviewedTitle': 'Vad ”Granskad” betyder',
|
||||
'admin.plugins.security.reviewedBody':
|
||||
'Ett granskat plugin har manuellt skannats efter skadlig kod av en TREK-underhållare i varje version — kontrollerat för fientlig kod, inte för om det fungerar bra. Det är inget löfte om att pluginet är ofarligt.',
|
||||
'admin.plugins.security.signedTitle': 'Vad ”Signerad” betyder',
|
||||
'admin.plugins.security.signedBody':
|
||||
'Kontrollsumman som TREK verifierar vid varje installation bevisar att filerna är exakt det som registret går i god för. En signatur bevisar något annat: att de kommer från upphovspersonen, signerade med en nyckel som bara hen har. Ett signerat plugin har båda delarna. Ett osignerat är inte osäkert — det bär helt enkelt en garanti mindre, och de flesta plugins i registret är osignerade i dag.',
|
||||
'admin.plugins.signed': 'Signerad',
|
||||
'admin.plugins.signedHint': 'Verifierad mot upphovspersonens signeringsnyckel när den installerades',
|
||||
'admin.plugins.unsigned': 'Osignerad',
|
||||
'admin.plugins.unsignedHint':
|
||||
'Filerna stämmer med det registret går i god för, men ingenting knyter dem till upphovspersonen. En garanti mindre — inte osäkert.',
|
||||
'admin.plugins.updateBlocked': 'Uppdateringen blockerad — {reason}',
|
||||
'admin.plugins.reviewBlock': 'Granska',
|
||||
'admin.plugins.retrusted': 'Ny signeringsnyckel betrodd — pluginet är uppdaterat',
|
||||
'admin.plugins.sig.title': 'Signaturen för {name} kunde inte verifieras',
|
||||
'admin.plugins.sig.keyChangedBody':
|
||||
'Upphovspersonens signeringsnyckel är inte den som pluginet installerades med. Upphovspersoner byter faktiskt nycklar — men en angripare som tagit över pluginet skulle se ut precis så här.',
|
||||
'admin.plugins.sig.invalidBody':
|
||||
'Filerna stämmer inte med upphovspersonens signatur. De är inte det som upphovspersonen signerade — antingen har de skadats eller så har någon manipulerat dem. Detta går inte att förbigå.',
|
||||
'admin.plugins.sig.missingBody':
|
||||
'Det här pluginet var signerat när du installerade det, men den nya versionen levereras utan signatur. TREK accepterar inte den försämringen i tysthet. Detta går inte att förbigå.',
|
||||
'admin.plugins.sig.incompleteBody':
|
||||
'Registerposten är halvsignerad: den anger en upphovspersonsnyckel men versionen bär ingen signatur (eller tvärtom). Det är ett misstag på pluginets sida. Detta går inte att förbigå.',
|
||||
'admin.plugins.sig.pinnedKey': 'Nyckeln det installerades med',
|
||||
'admin.plugins.sig.newKey': 'Nyckeln det erbjuder nu',
|
||||
'admin.plugins.sig.confirmOutOfBand':
|
||||
'TREK kan inte skilja ett legitimt nyckelbyte från en kapning — härifrån ser de identiska ut. Bekräfta den nya nyckeln med upphovspersonen via en kanal du redan litar på innan du accepterar den. När du gör det uppdateras pluginet och den nya nyckeln sparas.',
|
||||
'admin.plugins.sig.retrustConfirm': 'Lita på den nya nyckeln och uppdatera',
|
||||
'admin.plugins.sig.cancel': 'Lita inte på den',
|
||||
'admin.plugins.sig.consentUnsigned':
|
||||
'Ingenting knyter den här versionen till sin upphovsperson — filerna stämmer med registret, men de bär ingen signatur från upphovspersonen.',
|
||||
'admin.plugins.security.trustTitle': 'Sammanfattningsvis',
|
||||
'admin.plugins.security.trustBody':
|
||||
'Att installera ett plugin är som att installera vilken tredjepartsapp som helst: lägg bara till kod från upphovspersoner du litar på, och granska det själv först om du är osäker. TREK tar inget ansvar för tredjepartsplugins.',
|
||||
@@ -473,6 +504,10 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.dep.download': 'Ladda ner',
|
||||
'admin.plugins.dep.update': 'Uppdatera',
|
||||
'admin.plugins.dep.resolveHint': 'Laddar ner den senaste kompatibla versionen, inklusive dess egna beroenden.',
|
||||
'admin.plugins.dep.trekIncompatible': 'Kräver TREK {range} — den här servern kör {host}',
|
||||
'admin.plugins.dep.trekUnknown': 'Anger inte vilka TREK-versioner som stöds',
|
||||
'admin.plugins.installCompatible': 'Installera {version}',
|
||||
'admin.plugins.incompatible': 'Inkompatibel',
|
||||
'admin.plugins.accessTitle': 'Vad det har åtkomst till',
|
||||
'admin.plugins.connectsTitle': 'Ansluter till',
|
||||
'admin.plugins.detailsTitle': 'Detaljer',
|
||||
|
||||
@@ -102,7 +102,8 @@ const settings: TranslationStrings = {
|
||||
'settings.notificationPreferences.configure': 'Konfigurera',
|
||||
'settings.notificationPreferences.testSuccess': 'Testavisering skickad.',
|
||||
'settings.notificationPreferences.testFailed': 'Testet misslyckades.',
|
||||
'settings.notificationPreferences.pluginConfigured': 'Konfigurerat. Hantera dina uppgifter på pluginets inställningssida.',
|
||||
'settings.notificationPreferences.pluginConfigured':
|
||||
'Konfigurerat. Hantera dina uppgifter på pluginets inställningssida.',
|
||||
'settings.notificationPreferences.ntfy': 'Ntfy',
|
||||
'settings.notificationPreferences.noChannels':
|
||||
'Inga meddelandekanaler har konfigurerats. Be en administratör att ställa in e-post- eller webhook-meddelanden.',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user