Files
multica/server/pkg/db/queries/plugin.sql
T
f45b591623 MUL-6469: host plugin artifacts, bind installs to immutable versions (#7321)
* feat(plugins): host plugin artifacts, bind installs to immutable versions

A plugin used to be a URL. The manifest was frozen at install, but the surface
script was fetched from the author's server every time a panel opened, with no
integrity check — so an administrator consented to a manifest while the browser
ran whatever that host served that day, inside the scopes already granted. The
author's uptime was our uptime, every panel open leaked the reader's IP and
"who read which issue" to the author, and publishing at all required a public
HTTPS domain, which is why the repo's own deploy-sentinel example never ran.

The author now uploads an artifact bundle and Multica stores it:

- plugin_package / plugin_package_version / plugin_package_file. A version is
  insert-only, and the (package_id, version) unique index is what makes
  immutability a database rule rather than a convention.
- An installation names one version. Publishing another changes nothing for a
  workspace until an administrator upgrades, which is a second consent.
- The bundle is validated once, at publish: the manifest parses, every file it
  declares is present and loadable, and a surface entry with a top-level import
  is refused with the line named. That failure used to surface months later in
  a reader's browser.
- The host inlines the surface script into the document it generates, so the
  CSP names no remote script origin at all. `connect-src 'none'` now means it:
  a surface with no net: scope cannot reach anywhere, including its author.
- Install-by-URL is gone rather than kept alongside. Two paths would make "is
  this code frozen?" depend on how the plugin was installed.
- MULTICA_PLUGIN_DIR stays the development channel, but publishes an ordinary
  immutable version instead of being a second render path; re-publishing an
  unchanged version lands as `+dev.N`.

Hook endpoints and MCP servers remain on the author's own infrastructure.

Migration 376 drops existing installations: they name a source URL that is no
longer a concept, and the code they would run was never published here.
plugins_v1 has never left its feature flag and holds no production data — the
same call migration 344 made.

Closes MUL-6469

Co-authored-by: multica-agent <[email protected]>

* fix(plugins): serialize publish/install/delete, correct the isolation claim

Review of #7321 found five problems. Four are fixed here; the fifth is real,
needs its own design pass, and is now tracked and described honestly in the code
instead of claimed away.

Migration prefix collision. main took 376 for agent_task_durable_work_dir while
this branch was open, so `migration prefix 376 is reused` failed backend-tests.
Rebased and renumbered the six migrations to 377-382.

TOCTOU between delete and install/publish. Relationships are application-owned
by repository policy, so nothing made "the version this installation names still
exists" true across statements: `delete counts zero installs` -> `install reads
the version` -> `delete commits` -> `install commits` left an installation whose
panel 404s forever, and a publish racing a delete left a version whose package
row was gone. Publish, install and delete now hold a (workspace, plugin key)
advisory lock for their whole transaction, and delete counts installations
inside it. Both interleavings have regression tests that reproduce the bug with
the lock removed.

Failed publishes moved package state. upsertPackage ran before the version
transaction, so a republish that lost the unique index had already renamed the
package, and a first publish that failed afterwards left a plugin with zero
versions. It now runs inside the same transaction.

The top-level import check was wrong in BOTH directions. It read line prefixes,
so it missed `  import x` and `/* c */ import x` — and it refused a valid file
whose template literal contained the word at a line start, which is ordinary in
a surface that renders code samples. A refusal blocks a publish with no way
around it, so that false positive was the worse half. Replaced with a scanner
that skips comments and string/template literals; where it is imprecise it is
imprecise safely (`/` always reads as division), so a mistake can only cost a
detection, never invent one.

Sandbox self-navigation is NOT fixed. `sandbox="allow-scripts"` permits `_self`
navigation by design, no shipped CSP directive covers it, and contentWindow is
unchanged across it — so a hostile artifact can still reach its author once and
hand the replacement document the bridge port. Closing it needs an embedder
frame-src policy or a handshake a navigated document cannot complete; both are
decisions of their own. What lands here is damage control, labelled as such: the
generated document beacons on pagehide from a listener the plugin cannot detach,
and the embedder drops the bridge and unmounts the frame. The "a surface cannot
reach its author" wording is removed from the SDK README and the tests.

Closes MUL-6469
Refs MUL-6485

Co-authored-by: multica-agent <[email protected]>

* fix(plugins): harden surface validation and error reporting

* fix(migrations): renumber plugin package migrations

* fix(plugins): validate classic surfaces and reset failures

---------

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-21 16:03:44 +08:00

277 lines
10 KiB
SQL

-- name: LockPluginPackageKey :exec
-- Serializes publish, install and delete for one (workspace, plugin key).
--
-- Relationships are application-owned by repository policy, so there are no
-- foreign keys to make "this version still exists" true across statements.
-- Without this lock the interleaving `delete counts 0 installs` → `install
-- reads the version` → `delete commits` → `install commits` leaves an
-- installation pointing at a version that no longer exists, and its panel 404s
-- forever with nothing in the product able to explain why.
--
-- Keyed on the plugin key rather than the package id because publish has no
-- package id yet — the row it would lock is the one it may be about to create.
SELECT pg_advisory_xact_lock(hashtextextended($1::text, 0));
-- name: CreatePluginPackage :one
INSERT INTO plugin_package (workspace_id, plugin_key, name, created_by)
VALUES ($1, $2, $3, $4)
RETURNING *;
-- name: UpdatePluginPackageName :one
-- The display name follows the newest published version. The key never moves:
-- it is the identity an installation was consented under.
UPDATE plugin_package
SET name = $2,
updated_at = now()
WHERE id = $1
RETURNING *;
-- name: GetWorkspacePluginPackageByKey :one
SELECT * FROM plugin_package
WHERE workspace_id = $1 AND plugin_key = $2;
-- name: GetWorkspacePluginPackage :one
SELECT * FROM plugin_package
WHERE workspace_id = $1 AND id = $2;
-- name: ListWorkspacePluginPackages :many
SELECT * FROM plugin_package
WHERE workspace_id = $1
ORDER BY name ASC;
-- name: DeletePluginPackage :exec
DELETE FROM plugin_package WHERE id = $1;
-- name: CreatePluginPackageVersion :one
-- Published versions are only ever inserted. Nothing updates one, and the
-- (package_id, version) unique index is what makes a second publish of the same
-- version a conflict instead of a silent overwrite.
INSERT INTO plugin_package_version (
package_id, workspace_id, version, manifest, digest, size_bytes, published_by
) VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING *;
-- name: ListPluginPackageVersions :many
SELECT * FROM plugin_package_version
WHERE package_id = $1
ORDER BY created_at DESC;
-- name: GetWorkspacePluginPackageVersion :one
SELECT * FROM plugin_package_version
WHERE workspace_id = $1 AND id = $2;
-- name: DeletePluginPackageVersionsByPackage :exec
DELETE FROM plugin_package_version WHERE package_id = $1;
-- name: CountInstallationsOfPackageVersions :one
-- Whether any workspace still runs a version of this package. Publishing is
-- workspace-private, so this is scoped to the same workspace by construction.
SELECT count(*) FROM plugin_installation
WHERE package_version_id IN (
SELECT id FROM plugin_package_version WHERE package_id = $1
);
-- name: CreatePluginPackageFile :exec
INSERT INTO plugin_package_file (version_id, path, content, size_bytes, sha256)
VALUES ($1, $2, $3, $4, $5);
-- name: GetPluginPackageFile :one
SELECT * FROM plugin_package_file
WHERE version_id = $1 AND path = $2;
-- name: ListPluginPackageFilePaths :many
-- Paths and sizes only: the publisher's file list never needs the bytes.
SELECT path, size_bytes, sha256 FROM plugin_package_file
WHERE version_id = $1
ORDER BY path ASC;
-- name: DeletePluginPackageFilesByPackage :exec
DELETE FROM plugin_package_file
WHERE version_id IN (SELECT id FROM plugin_package_version WHERE package_id = $1);
-- name: CreatePluginInstallation :one
INSERT INTO plugin_installation (
workspace_id, plugin_key, package_version_id, version, manifest, granted_scopes, installed_by
) VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING *;
-- name: UpdatePluginInstallationManifest :one
-- Upgrade path: the installation is re-pointed at another published version and
-- takes that version's consented manifest snapshot. Config values survive on
-- purpose; fields the new manifest dropped are pruned by the service before this
-- runs.
UPDATE plugin_installation
SET package_version_id = $2,
version = $3,
manifest = $4,
granted_scopes = $5,
config = $6,
updated_at = now()
WHERE id = $1
RETURNING *;
-- name: UpdatePluginInstallationConfig :one
UPDATE plugin_installation
SET config = $2,
updated_at = now()
WHERE id = $1
RETURNING *;
-- name: SetPluginInstallationEnabled :one
UPDATE plugin_installation
SET enabled = $2,
updated_at = now()
WHERE id = $1
RETURNING *;
-- name: GetPluginInstallation :one
SELECT * FROM plugin_installation WHERE id = $1;
-- name: GetWorkspacePluginInstallation :one
SELECT * FROM plugin_installation
WHERE workspace_id = $1 AND id = $2;
-- name: GetWorkspacePluginInstallationByKey :one
SELECT * FROM plugin_installation
WHERE workspace_id = $1 AND plugin_key = $2;
-- name: ListWorkspacePluginInstallations :many
SELECT * FROM plugin_installation
WHERE workspace_id = $1
ORDER BY created_at ASC;
-- name: DeletePluginInstallation :exec
DELETE FROM plugin_installation WHERE id = $1;
-- name: UpsertPluginStorageValue :one
INSERT INTO plugin_storage (installation_id, scope_type, scope_id, key, value)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (installation_id, scope_type, scope_id, key)
DO UPDATE SET value = EXCLUDED.value, updated_at = now()
RETURNING *;
-- name: GetPluginStorageValue :one
SELECT * FROM plugin_storage
WHERE installation_id = $1 AND scope_type = $2 AND scope_id = $3 AND key = $4;
-- name: ListPluginStorageKeys :many
SELECT key, octet_length(value)::bigint AS size_bytes, updated_at
FROM plugin_storage
WHERE installation_id = $1 AND scope_type = $2 AND scope_id = $3
ORDER BY key ASC;
-- name: DeletePluginStorageValue :execrows
DELETE FROM plugin_storage
WHERE installation_id = $1 AND scope_type = $2 AND scope_id = $3 AND key = $4;
-- name: GetPluginStorageUsage :one
-- Quota accounting for one (installation, scope) pair. The candidate key is
-- excluded so overwriting an existing key is measured as a replacement rather
-- than an addition. octet_length, not char_length: the service compares these
-- against byte budgets, and a UTF-8 character is up to 4 bytes.
SELECT COUNT(*)::bigint AS key_count,
COALESCE(SUM(octet_length(value)), 0)::bigint AS total_bytes
FROM plugin_storage
WHERE installation_id = $1 AND scope_type = $2 AND scope_id = $3 AND key <> $4;
-- name: DeletePluginStorageByInstallation :exec
DELETE FROM plugin_storage WHERE installation_id = $1;
-- name: UpsertPluginSecret :exec
INSERT INTO plugin_secret (installation_id, key, ciphertext)
VALUES ($1, $2, $3)
ON CONFLICT (installation_id, key)
DO UPDATE SET ciphertext = EXCLUDED.ciphertext, updated_at = now();
-- name: ListPluginSecretKeys :many
-- Deliberately never selects ciphertext: the presence of a secret is readable,
-- its value is not.
SELECT key, updated_at FROM plugin_secret
WHERE installation_id = $1
ORDER BY key ASC;
-- name: GetPluginSecret :one
SELECT * FROM plugin_secret
WHERE installation_id = $1 AND key = $2;
-- name: DeletePluginSecret :execrows
DELETE FROM plugin_secret WHERE installation_id = $1 AND key = $2;
-- name: DeletePluginSecretsByInstallation :exec
DELETE FROM plugin_secret WHERE installation_id = $1;
-- name: SetPluginInstallationToken :exec
UPDATE plugin_installation
SET token_hash = $2, token_rotated_at = now(), updated_at = now()
WHERE id = $1;
-- name: GetPluginInstallationByTokenHash :one
-- Looked up by hash, so the plaintext token exists only in the caller's request.
SELECT * FROM plugin_installation WHERE token_hash = $1;
-- name: CreatePluginInvocation :one
INSERT INTO plugin_invocation (
installation_id, workspace_id, hook_key, trigger, status, event_type, attempt, latency_ms, error
) VALUES ($1, $2, $3, $4, $5, sqlc.narg(event_type), $6, $7, sqlc.narg(error))
RETURNING *;
-- name: ListPluginInvocations :many
SELECT * FROM plugin_invocation
WHERE installation_id = $1
ORDER BY created_at DESC
LIMIT $2;
-- name: CountRecentPluginInvocations :one
-- Feeds the per-hook rate limit. Counts attempts, not distinct calls: a hook
-- retrying into a dead endpoint is exactly the traffic the limit exists to cap.
SELECT count(*) FROM plugin_invocation
WHERE installation_id = $1 AND hook_key = $2 AND created_at > $3;
-- name: CountRecentPluginFailures :one
-- Consecutive-failure signal for the event circuit breaker. Bounded by time so
-- a breaker that tripped hours ago does not keep a hook shut forever.
SELECT count(*) FROM plugin_invocation
WHERE installation_id = $1 AND hook_key = $2 AND created_at > $3 AND status <> 'ok';
-- name: DeletePluginInvocationsByInstallation :exec
DELETE FROM plugin_invocation WHERE installation_id = $1;
-- name: DeleteExpiredPluginInvocations :execrows
-- TTL sweep. This table is operational telemetry, not history to keep.
DELETE FROM plugin_invocation WHERE created_at < $1;
-- name: UpsertPluginSkill :one
-- A plugin's skill resource, as an ordinary workspace skill.
--
-- Upsert on (workspace_id, name) because that is the table's own uniqueness
-- rule and an upgrade re-installs the same skill. The WHERE clause is the
-- important half: it refuses to overwrite a skill a PERSON created, or one
-- another installation owns. A plugin claiming a name someone already used must
-- fail the install loudly, not silently replace their work.
INSERT INTO skill (workspace_id, name, description, content, config, created_by, plugin_installation_id)
VALUES ($1, $2, $3, $4, '{}'::jsonb, sqlc.narg(created_by), $5)
ON CONFLICT (workspace_id, name) DO UPDATE SET
description = EXCLUDED.description,
content = EXCLUDED.content,
updated_at = now()
WHERE skill.plugin_installation_id = EXCLUDED.plugin_installation_id
RETURNING *;
-- name: ListPluginSkills :many
SELECT * FROM skill WHERE plugin_installation_id = $1 ORDER BY name ASC;
-- name: DeletePluginSkillsByInstallation :exec
DELETE FROM skill WHERE plugin_installation_id = $1;
-- name: DeletePluginSkillsNotIn :exec
-- Upgrade pruning: a skill this installation used to contribute but no longer
-- declares must go, or a renamed skill leaves its predecessor behind forever.
DELETE FROM skill
WHERE plugin_installation_id = $1 AND name <> ALL(@keep_names::text[]);
-- name: SetPluginMCPApprovals :one
UPDATE plugin_installation
SET mcp_approvals = $2, updated_at = now()
WHERE id = $1
RETURNING *;