Creating a custom module

A Vercia module is a self-contained domain: its own Postgres schema, Drizzle tables, service class, manifest, event types, and permissions. It integrates with the platform via @vercia/module-sdk and @vercia/kernel.

When to create a module

Create a module when you have:

  • A distinct domain with its own tables (not just extra columns on existing ones)
  • Permissions that other modules or tenants need to check
  • Events that other parts of the system should react to
  • A set of entitlements tied to billing plans

For thin CRUD that lives entirely within an existing module's domain, extend that module's service instead.

Generate the scaffold

``bash

pnpm platform module:create --id my-feature --name "My Feature"

`

This creates modules/my-feature/ with the standard structure and wires it into pnpm-workspace.yaml.

Module structure

`

modules/my-feature/

src/

index.ts public exports

manifest.ts data-only manifest (id, permissions, events, …)

my-feature-service.ts

repos/

my-feature-repo.ts

schema/

schema.ts Drizzle table definitions + RLS policies

migrations.ts inline migrations list

tests/

service.test.ts

isolation.test.ts

package.json

tsconfig.json

vitest.config.ts

`

1. Define the manifest

`typescript

// src/manifest.ts

import type { PlatformModuleManifest } from "@vercia/module-sdk";

export const myFeatureManifest: PlatformModuleManifest = {

id: "my-feature",

name: "My Feature",

version: "0.1.0",

platformRange: ">=0.1.0",

dependencies: [],

capabilities: ["content"],

permissions: [

{ key: "my-feature.widget.read", name: "Read widgets", scope: "tenant" },

{ key: "my-feature.widget.create", name: "Create widgets", scope: "tenant" },

{ key: "my-feature.widget.delete", name: "Delete widgets", scope: "tenant" },

],

settings: [],

featureFlags: [],

entitlements: [

{ key: "my-feature.widget_count", name: "Widget limit", unit: "count" },

],

navigation: [

{ label: "Widgets", route: "/admin/widgets", permission: "my-feature.widget.read" },

],

adminResources: [],

blocks: [],

events: {

published: [

{ type: "my-feature.widget.created", version: 1, payloadSchema: {} },

{ type: "my-feature.widget.deleted", version: 1, payloadSchema: {} },

],

consumed: [],

},

jobs: [],

webhooks: [],

healthChecks: [{ key: "my-feature.db", critical: true }],

dataClassification: "standard",

};

`

2. Define the schema

`typescript

// src/schema/schema.ts

import { pgTable, text, timestamp, index } from "drizzle-orm/pg-core";

import { sql } from "drizzle-orm";

export const myFeatureWidgets = pgTable(

"my_feature_widgets",

{

id: text("id").primaryKey(),

tenantId: text("tenant_id").notNull(),

name: text("name").notNull(),

createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),

},

(t) => [index("my_feature_widgets_tenant_idx").on(t.tenantId)],

);

`

`typescript

// src/schema/migrations.ts

import type { InlineMigration } from "@vercia/database";

export const myFeatureMigrations: InlineMigration[] = [

{

version: "my_feature_0001",

sql:

CREATE TABLE IF NOT EXISTS my_feature_widgets (

id TEXT PRIMARY KEY,

tenant_id TEXT NOT NULL,

name TEXT NOT NULL,

created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()

);

ALTER TABLE my_feature_widgets ENABLE ROW LEVEL SECURITY;

ALTER TABLE my_feature_widgets FORCE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON my_feature_widgets

USING (tenant_id = current_setting('app.tenant_id', TRUE));

,

},

];

`

Always add RLS. Every table must have ENABLE ROW LEVEL SECURITY + a policy on tenant_id = current_setting('app.tenant_id', TRUE). The platform enforces this; missing it is a security defect.

3. Write the repository

`typescript

// src/repos/my-feature-repo.ts

import type { DbHandle } from "@vercia/database";

import { myFeatureWidgets } from "../schema/schema.js";

import { eq } from "drizzle-orm";

export class MyFeatureRepo {

constructor(private readonly db: DbHandle) {}

async list(tenantId: string) {

return this.db.select().from(myFeatureWidgets).where(eq(myFeatureWidgets.tenantId, tenantId));

}

async create(row: typeof myFeatureWidgets.$inferInsert) {

const [created] = await this.db.insert(myFeatureWidgets).values(row).returning();

return created!;

}

async delete(id: string) {

await this.db.delete(myFeatureWidgets).where(eq(myFeatureWidgets.id, id));

}

}

`

4. Write the service

`typescript

// src/my-feature-service.ts

import type { RequestContext } from "@vercia/kernel";

import type { AuthorizationPort } from "@vercia/authorization";

import type { AuditWriter, OutboxStore } from "@vercia/kernel";

import { errors, makeIntegrationEvent, outboxRecordFromEvent } from "@vercia/kernel";

import { IdGenerator } from "@vercia/kernel";

import type { DbHandle } from "@vercia/database";

import { MyFeatureRepo } from "./repos/my-feature-repo.js";

export class MyFeatureService {

private readonly repo: MyFeatureRepo;

constructor(

db: DbHandle,

private readonly deps: {

authz: AuthorizationPort;

audit: AuditWriter;

outbox: OutboxStore;

},

) {

this.repo = new MyFeatureRepo(db);

}

async listWidgets(ctx: RequestContext) {

await this.deps.authz.requirePermission(ctx, "my-feature.widget.read");

return this.repo.list(ctx.tenantId!);

}

async createWidget(ctx: RequestContext, input: { name: string }) {

await this.deps.authz.requirePermission(ctx, "my-feature.widget.create");

const id = IdGenerator.generate();

const widget = await this.repo.create({ id, tenantId: ctx.tenantId!, name: input.name });

await this.deps.audit.write({

tenantId: ctx.tenantId!,

actor: ctx.actor,

action: "my-feature.widget.created",

resourceType: "widget",

resourceId: id,

});

await this.deps.outbox.append(

outboxRecordFromEvent(

makeIntegrationEvent({

type: "my-feature.widget.created",

version: 1,

tenantId: ctx.tenantId!,

actor: { kind: ctx.actor.kind, id: ctx.actor.userId! },

correlationId: ctx.correlationId,

payload: { widgetId: id, name: input.name },

}),

),

);

return widget;

}

}

`

Key rules:

  • Always call authz.requirePermission before reading or mutating
  • Always write an audit entry on every mutation
  • Emit domain events via the outbox (not a direct publish call) so events are durable
  • Never throw raw errors — use errors.forbidden(), errors.notFound(), etc. from @vercia/kernel

5. Wire into the API

`typescript

// apps/api/src/my-feature.controller.ts

import { Controller, Get, Post, Body, Req, UseGuards } from "@nestjs/common";

import { AuthGuard } from "./auth.guard.js";

import { TenantContextMiddleware } from "./tenant-context.js";

import type { AuthenticatedRequest } from "./auth.guard.js";

import { MyFeatureService } from "@vercia/module-my-feature";

import { errors } from "@vercia/kernel";

import { withTenantTransaction } from "@vercia/database";

import { z } from "zod";

const createWidgetSchema = z.object({ name: z.string().min(1).max(200) });

@Controller("v1/my-feature")

@UseGuards(AuthGuard)

export class MyFeatureController {

constructor(private readonly service: MyFeatureService) {}

private ctx(req: AuthenticatedRequest) {

const ctx = req.authContext;

if (!ctx) throw errors.unauthenticated();

return ctx;

}

@Get("widgets")

async list(@Req() req: AuthenticatedRequest) {

return withTenantTransaction(this.ctx(req), (ctx) => this.service.listWidgets(ctx));

}

@Post("widgets")

async create(@Body() body: unknown, @Req() req: AuthenticatedRequest) {

const input = createWidgetSchema.parse(body);

return withTenantTransaction(this.ctx(req), (ctx) => this.service.createWidget(ctx, input));

}

}

`

Register the controller in apps/api/src/platform.module.ts:

`typescript

// In the controllers array:

MyFeatureController,

// In the providers array:

{

provide: "MY_FEATURE_SERVICE",

useFactory: (db, authz, audit, outbox) =>

new MyFeatureService(db, { authz, audit, outbox }),

inject: [PLATFORM_TOKENS.db, PLATFORM_TOKENS.authz, PLATFORM_TOKENS.audit, PLATFORM_TOKENS.outbox],

},

`

6. Add to a recipe

`typescript

// recipes/saas/src/index.ts (or your own recipe)

import { myFeatureManifest, myFeatureMigrations } from "@vercia/module-my-feature";

modules: [

// ... existing modules ...

{ id: "my-feature", manifest: myFeatureManifest, migrations: myFeatureMigrations, required: false },

],

`

The API will run myFeatureMigrations at next boot.

7. Write tests

`typescript

// tests/service.test.ts

import { describe, it, expect, beforeEach, afterEach } from "vitest";

import { createTestDatabase, TestDatabase } from "@vercia/testing";

import { MyFeatureService } from "../src/my-feature-service.js";

import { makeTestContext } from "@vercia/testing";

import { MemoryAuditLog, MemoryOutboxStore } from "@vercia/kernel";

import { FakeAuthorization } from "@vercia/testing";

let db: TestDatabase;

beforeEach(async () => { db = await createTestDatabase(myFeatureMigrations); });

afterEach(async () => { await db.cleanup(); });

it("creates a widget and emits an event", async () => {

const outbox = new MemoryOutboxStore();

const svc = new MyFeatureService(db.handle, {

authz: new FakeAuthorization(),

audit: new MemoryAuditLog(),

outbox,

});

const ctx = makeTestContext({ tenantId: "tenant-1", userId: "user-1" });

const widget = await svc.createWidget(ctx, { name: "Hello" });

expect(widget.name).toBe("Hello");

expect(outbox.pending()).toHaveLength(1);

expect(outbox.pending()[0]!.type).toBe("my-feature.widget.created");

});

`

`typescript

// tests/isolation.test.ts — verify RLS

it("cannot read another tenant's widgets", async () => {

const svc = new MyFeatureService(db.handle, { /* ... */ });

const ctx1 = makeTestContext({ tenantId: "a", userId: "u1" });

const ctx2 = makeTestContext({ tenantId: "b", userId: "u2" });

await svc.createWidget(ctx1, { name: "Secret" });

const visible = await svc.listWidgets(ctx2);

expect(visible).toHaveLength(0);

});

`

Run with:

`bash

pnpm --filter @vercia/module-my-feature test

`

8. React to events from other modules

In apps/worker/src/main.ts, add a handler:

`typescript

handlers.set("identity.user.created", async (event) => {

const { payload } = event as { payload: { userId: string; email: string } };

// create a default widget for every new user

// (use a platform-level db connection with BYPASSRLS for cross-tenant writes)

});

`

Checklist before shipping a module

  • [ ] Every table has ENABLE ROW LEVEL SECURITY + policy on tenant_id
  • [ ] Every public method calls authz.requirePermission
  • [ ] Every mutation writes an audit entry
  • [ ] Mutations that produce domain events append to the outbox (not direct publish)
  • [ ] Module exports manifest and migrations from src/index.ts
  • [ ] Manifest lists all permissions, events, entitlements, health checks
  • [ ] Service tests cover the happy path and an RLS isolation test
  • [ ] pnpm typecheck green
  • [ ] pnpm --filter @vercia/module-<id> test green
  • [ ] Added to a recipe's modules` array