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… | Use | Where (file:line) | Never |
|---|---|---|---|
| fetch / proxy a tenant-supplied URL safely (SSRF, safe url) | App\Support\OutboundUrl::assertSafe() | app/Support/OutboundUrl.php:30 | filter_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 job | App\Support\SystemContext::runUnscoped() | app/Support/SystemContext.php:54 | a 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:24 | firing 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 route | App\Support\ApiResponse::success() | app/Support/ApiResponse.php:19 | response()->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 route | App\Support\ApiResponse::error() | app/Support/ApiResponse.php:29 | hand-rolling {status:'error'} without traceId — breaks trace correlation and the envelope contract. |
| presign a client upload | App\Storage\ObjectStore::signedUploadUrl() | app/Storage/ObjectStore.php:19 | a direct S3 client — bypasses the driver seam, sets the wrong PUT headers, and is untestable against MinIO. |
| presign a client download | App\Storage\ObjectStore::signedDownloadUrl() | app/Storage/ObjectStore.php:22 | caching 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-side | App\Storage\ObjectStore::read() | app/Storage/ObjectStore.php:29 | assuming 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 value | App\Plugins\HostStore::get() | app/Plugins/HostStore.php:28 | querying 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 value | App\Plugins\HostStore::put() | app/Plugins/HostStore.php:69 | a 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 namespace | App\Plugins\HostStore::list() | app/Plugins/HostStore.php:49 | an unbounded ->get() over the KV table — always cross a tenant/plugin boundary; list() caps and scopes it. |
| delete a KV entry | App\Plugins\HostStore::delete() | app/Plugins/HostStore.php:92 | a 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:115 | trusting that a request reached you — a disabled plugin’s stored data is still queryable unless you assert first. |
| read the tenant’s merged plugin settings | App\Plugins\PluginSettings::resolve() | app/Plugins/PluginSettings.php:15 | reading data_json off plugin_namespaces yourself — skips the default→group→tenant cascade and the sensitive-field masking. |
| save a tenant’s plugin-setting override | App\Plugins\PluginSettings::setOverride() | app/Plugins/PluginSettings.php:29 | writing the settings JSON directly — bypasses per-field type/range validation and lets unknown keys through. |
| ask another plugin to create a Task | App\Events\TaskRequested | app/Events/TaskRequested.php:15 | expecting a synchronous listener — it is Outboxed, delivered only under outbox:relay; nothing runs in-request. |
| request a feed / activity event | App\Events\FeedEventRequested | app/Events/FeedEventRequested.php:16 | writing feed_events directly — the feed plugin’s WriteFeedEvent is the sole writer; a direct write desyncs. |
| make a new model tenant-isolated at query time | App\Support\Concerns\BelongsToTenant | app/Support/Concerns/BelongsToTenant.php:9 | adding 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 key | App\Support\Concerns\HasStringUuid | app/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 resource | App\Plugins\Concerns\AdminManageable | app/Plugins/Concerns/AdminManageable.php:19 | Laravel’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-revert | App\Audit\AuditableRegistry::register() | app/Audit/AuditableRegistry.php:20 | relying only on config('audit.allowlist') — a plugin model isn’t audited or field-revertable until registered from its ServiceProvider. |
| verify a plugin artifact’s signature | App\Plugins\ArtifactSigning::verifyBytes() | app/Plugins/ArtifactSigning.php:51 | openssl_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 range | App\Plugins\SemverRange::satisfies() | app/Plugins/SemverRange.php:23 | passing 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 calls | the session-api middleware group | bootstrap/app.php group def, lines 68–71 | EnsureFrontendRequestsAreStateful 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 migration | the 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