Plugin Contract
Rules every plugin author must follow to inherit the framework’s security guarantees. Violating these means your plugin is an attack surface, not an extension point.
Why this contract exists
Section titled “Why this contract exists”In commit 7cb06e3 the plugin-reviews service accepted customerId directly from the request body. Any authenticated customer could submit a review under a different customer’s identity by changing one field. This is the same IDOR class that affected carts before it: if every plugin trusts body-supplied customerId, every plugin is a cross-customer hijack vector. The contract below codifies the fix pattern so adopters don’t rebuild the same bug.
Required signature shape
Section titled “Required signature shape”Every plugin service method that mutates data accepts actor: Actor | null as a required parameter — not optional, not defaulted to null.
// Correctasync submit(orgId: string, input: SubmitInput, actor: Actor | null): Promise<PluginResult<Review>>
// Wrong -- no actor, no accountabilityasync submit(orgId: string, input: SubmitInput): Promise<PluginResult<Review>>Reject anonymous callers on mutations:
if (!actor?.userId) { return Err("Authentication required");}For end-user (customer) roles, resolve the customer profile UUID server-side via customers.getByUserId(actor.userId, actor) and use that as the customerId. Never trust input.customerId for customer-role actors.
Staff/admin override
Section titled “Staff/admin override”These roles are trusted to supply customerId via input (POS terminal, agent assist, admin panel):
staffadminownerai_agentservice
const staffRoles = new Set(["staff", "admin", "owner", "ai_agent", "service"]);const isStaff = typeof actor.role === "string" && staffRoles.has(actor.role);
let resolvedCustomerId: string | null = null;if (isStaff) { resolvedCustomerId = input.customerId ?? null;} else { // Resolve from session -- never from input const profile = await customers.getByUserId(actor.userId, actor); if (!profile.ok) return Err("Customer profile not found"); resolvedCustomerId = profile.value.id;}Reference: packages/plugins/plugin-reviews/src/services/review-service.ts:41--57.
Result contract
Section titled “Result contract”Services never throw across module boundaries. Return Result<T> via Ok / Err from @unifiedcommerce/core.
For plugin services, use PluginResult<T> — the simplified { ok: true; value: T } | { ok: false; error: string } form. See packages/core/src/kernel/result.ts.
Core services use the richer Result<T, CommerceError> with typed error classes from packages/core/src/kernel/errors.ts:
CommerceForbiddenError— authorization failureCommerceNotFoundError— resource does not existCommerceValidationError— input constraint violationCommerceConflictError— concurrent state conflictCommerceInvalidTransitionError— state machine guardOrgResolutionError— tenant resolution failure
Org scoping
Section titled “Org scoping”Every Drizzle query on a tenant-scoped table must include eq(table.organizationId, orgId) in the WHERE clause. The framework resolves orgId via resolveOrgId(actor) (packages/core/src/auth/org.ts).
Tenant-scoped tables:
carts,cart_line_itemsorders,order_line_items,order_status_historycustomers,customer_addresses,customer_groups,customer_group_membersinventory_levels,inventory_movements,warehousesprices,price_modifierspromotions,promotion_usageswebhook_endpoints,processed_webhook_eventsmedia_assetssellable_entities,sellable_attributes,sellable_custom_fields,variants,option_types,option_valuescategories,brandscommerce_audit_log
Omitting the org filter on any of these tables leaks cross-tenant data. The webhook cross-tenant bug (webhooks/service.ts) and the media cross-tenant bug (media/service.ts) were both caused by missing org filters.
Hook ordering
Section titled “Hook ordering”Hooks are resolved positionally: [...prepended, ...configured, ...appended]. Two plugins both prepending to orders.afterCreate will execute in registration order. There is no runs.before / runs.after declarative ordering. Test your plugin composition, especially when multiple plugins touch the same hook.
Reference: packages/core/src/kernel/hooks/registry.ts.
Test contract
Section titled “Test contract”Every plugin ships at least one regression test per security-relevant code path. The minimum:
- Customer-role actor cannot spoof
customerId. - Anonymous callers are rejected on mutations.
- Org isolation: a different org sees zero records.
Reference: packages/plugins/plugin-reviews/test/reviews.test.ts, specifically the "ignores spoofed customerId for customer-role actor" and "org isolation" test cases.
Anti-patterns to avoid
Section titled “Anti-patterns to avoid”Trust input.customerId from end-users. Allows cross-customer identity hijack. Always resolve server-side for customer roles.
Throw across module boundaries. Breaks the Result<T> contract. Callers cannot distinguish business errors from runtime crashes.
Skip the org filter on a “convenience” query. Cross-tenant data leak. Every query on a tenant-scoped table needs the filter.
Catch and swallow errors. Silent failures hide bugs and mask attacks. Propagate via Err().
Use as any to bypass type errors. The type system is a security boundary here. If the types don’t line up, fix the types.
Example diff — the plugin-reviews IDOR fix
Section titled “Example diff — the plugin-reviews IDOR fix”Before (vulnerable):
// review-service.ts -- BEFOREasync submit(orgId: string, input: { customerId: string; // <-- trusted directly from request body entityId: string; rating: number; title?: string; body?: string;}): Promise<PluginResult<Review>> { const rows = await this.db.insert(customerReviews).values({ organizationId: orgId, customerId: input.customerId, // <-- spoofed value persisted entityId: input.entityId, rating: input.rating, // ... }).returning(); return Ok(rows[0]!);}After (fixed):
// review-service.ts -- AFTERasync submit(orgId: string, input: { customerId?: string; // <-- optional, ignored for customer roles entityId: string; rating: number; title?: string; body?: string;}, actor: Actor | null): Promise<PluginResult<Review>> { // <-- actor required if (!actor?.userId) return Err("Authentication required");
const staffRoles = new Set(["staff", "admin", "owner", "ai_agent", "service"]); const isStaff = typeof actor.role === "string" && staffRoles.has(actor.role);
let resolvedCustomerId: string | null = null; if (isStaff) { resolvedCustomerId = input.customerId ?? null; } else { const profile = await customers.getByUserId(actor.userId, actor); if (!profile.ok) return Err("Customer profile not found"); resolvedCustomerId = profile.value.id; // <-- server-side resolution }
const rows = await this.db.insert(customerReviews).values({ organizationId: orgId, customerId: resolvedCustomerId, // <-- never from input for customers entityId: input.entityId, rating: input.rating, // ... }).returning(); return Ok(rows[0]!);}This is the canonical pattern. Apply it to every plugin service method that accepts an identity field.