mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-08 17:36:45 +08:00
* 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]>
277 lines
10 KiB
SQL
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 *;
|