Skip to Content
PluginsPlugin capabilities

Plugin capabilities

A VCTRbase plugin builds on the platform instead of reinventing it. Each row is a task a plugin commonly needs — fetch a URL safely, isolate tenant data, presign an upload, emit an event across plugins — paired with the built-in class that does it and where it lives. The Never column names the hand-rolled look-alike to avoid.

I need to…UseWhere (file:line)Never
fetch / proxy a tenant-supplied URL safely (SSRF, safe url)App\Support\OutboundUrl::assertSafe()app/Support/OutboundUrl.php:30filter_var($url, FILTER_VALIDATE_URL) + FILTER_FLAG_NO_PRIV_RANGE — misses 0x7f000001, 2130706433, 127.1, 0177.0.0.1. This is exactly the SafeUrl mistake.
read/write across tenants in cron or a queue jobApp\Support\SystemContext::runUnscoped()app/Support/SystemContext.php:54a plain query — the TenantScope global scope silently returns only the bound tenant’s rows (or zero rows, no error if none is bound).
run a job as one specific tenant (outside an HTTP request)App\Support\SystemContext::runAsTenant()app/Support/SystemContext.php:24firing the job with no context bound — writes fail RLS, audit rows get a null actor, reads return zero rows.
return a success body from an /api/v1 routeApp\Support\ApiResponse::success()app/Support/ApiResponse.php:19response()->json([...]) raw — @vctrs/plugin-ui’s unwrap() expects the {traceId,data,status} envelope and throws on a bare body.
return an error body from an /api/v1 routeApp\Support\ApiResponse::error()app/Support/ApiResponse.php:29hand-rolling {status:'error'} without traceId — breaks trace correlation and the envelope contract.
presign a client uploadApp\Storage\ObjectStore::signedUploadUrl()app/Storage/ObjectStore.php:19a direct S3 client — bypasses the driver seam, sets the wrong PUT headers, and is untestable against MinIO.
presign a client downloadApp\Storage\ObjectStore::signedDownloadUrl()app/Storage/ObjectStore.php:22caching or returning a permanent public URL — leaks the object; the short TTL is the security boundary.
read the raw bytes of a stored object server-sideApp\Storage\ObjectStore::read()app/Storage/ObjectStore.php:29assuming a throw on a missing key — a missing key returns '', never throws; check exists() if you need the distinction.
read my plugin’s per-tenant KV valueApp\Plugins\HostStore::get()app/Plugins/HostStore.php:28querying plugin_store_entries directly — you must scope by tenant and slug or you read another tenant’s or plugin’s value.
write my plugin’s per-tenant KV valueApp\Plugins\HostStore::put()app/Plugins/HostStore.php:69a raw upsert — misses the (tenant, slug, namespace, key) uniqueness and the tenant stamping HostStore does for you.
list my plugin’s KV entries in a namespaceApp\Plugins\HostStore::list()app/Plugins/HostStore.php:49an unbounded ->get() over the KV table — always cross a tenant/plugin boundary; list() caps and scopes it.
delete a KV entryApp\Plugins\HostStore::delete()app/Plugins/HostStore.php:92a hand-written DELETE … WHERE key = ? — with no tenant/slug scope it deletes across tenants.
gate in-process store access behind “plugin enabled”App\Plugins\HostStore::assertEnabled()app/Plugins/HostStore.php:115trusting that a request reached you — a disabled plugin’s stored data is still queryable unless you assert first.
read the tenant’s merged plugin settingsApp\Plugins\PluginSettings::resolve()app/Plugins/PluginSettings.php:15reading data_json off plugin_namespaces yourself — skips the default→group→tenant cascade and the sensitive-field masking.
save a tenant’s plugin-setting overrideApp\Plugins\PluginSettings::setOverride()app/Plugins/PluginSettings.php:29writing the settings JSON directly — bypasses per-field type/range validation and lets unknown keys through.
ask another plugin to create a TaskApp\Events\TaskRequestedapp/Events/TaskRequested.php:15expecting a synchronous listener — it is Outboxed, delivered only under outbox:relay; nothing runs in-request.
request a feed / activity eventApp\Events\FeedEventRequestedapp/Events/FeedEventRequested.php:16writing feed_events directly — the feed plugin’s WriteFeedEvent is the sole writer; a direct write desyncs.
make a new model tenant-isolated at query timeApp\Support\Concerns\BelongsToTenantapp/Support/Concerns/BelongsToTenant.php:9adding where('tenant_id', …) by hand on every query — you will miss one; the global scope + creating hook is the guarantee.
give a model a UUID string primary keyApp\Support\Concerns\HasStringUuidapp/Support/Concerns/HasStringUuid.php:7$table->id() / auto-increment PKs — leaks row counts and breaks the string-UUID contract other tables FK to.
add soft-delete + edit auditing to a resourceApp\Plugins\Concerns\AdminManageableapp/Plugins/Concerns/AdminManageable.php:19Laravel’s SoftDeletes trait — it writes no audit row and lacks the deleted_by_id / delete_reason / edit_count columns this expects.
register a model for auditing + admin field-revertApp\Audit\AuditableRegistry::register()app/Audit/AuditableRegistry.php:20relying only on config('audit.allowlist') — a plugin model isn’t audited or field-revertable until registered from its ServiceProvider.
verify a plugin artifact’s signatureApp\Plugins\ArtifactSigning::verifyBytes()app/Plugins/ArtifactSigning.php:51openssl_verify on DER/PEM keys — keys and signatures here are base64 raw Ed25519 bytes, not DER/SPKI; lengths won’t match.
test an engines.vctrbase version rangeApp\Plugins\SemverRange::satisfies()app/Plugins/SemverRange.php:23passing a 1.x / 1.2 partial — only full X.Y.Z is parsed; a partial silently becomes 1.0.0.
authenticate an extracted plugin’s browser /api/v1 callsthe session-api middleware groupbootstrap/app.php group def, lines 68–71EnsureFrontendRequestsAreStateful or the Bearer api group — re-runs EncryptCookies and risks double-encrypting cookies; see the comment at bootstrap/app.php lines 57–67.
add a new tenant table in a migrationthe manual ENABLE + CREATE POLICY … _tenant_isolation + FORCE recipe (no helper yet)see docs/PLUGINS.md → “RLS obligations”a bare Schema::create — the ALTER DEFAULT PRIVILEGES grant in baseline_schema auto-grants CRUD to app_user, leaving the table readable across every tenant with no policy. Safety net: php artisan rls:coverage.
Last updated on