Standards Docs
Guides

NestJS Backend

Wire @stndrds/adapter-nestjs SchemaModule with all five adapters used by standards-platform.

This guide walks through wiring @stndrds/adapter-nestjs's SchemaModule with the five adapters used by standards-platform: Supabase, Meilisearch, Redis, BullMQ, and E2B.

Breaking in vNEXT: Actor is now the single canonical identity. userId is gone from TenantContext, services, repositories, and PolicyContext — use actorId. withTenantContext(tenantId, fn, actorId?) lost its userId argument. The TenantContextInterceptor auto-provisions an actor for authenticated users, so resolveUserId in the snippet below resolves an actor id (the parameter name is unchanged for compatibility, but downstream code reads it from actorId).

Full module setup

The snippet below mirrors standards-platform/apps/api/src/modules/shared/schema/schema.module.ts. Trim sections you don't need yet — start with Supabase only, then add the rest.

Breaking in vNEXT: the legacy job path is removed. queue, agents.agentWorker, and agents.eventWorker (along with BullMQQueueAdapter / BullMQWorkerAdapter) no longer exist. Background jobs — agent runs, document OCR, computed recalculation — now flow exclusively through async: { transport: new BullMQBackgroundJobTransport(...) }. schedule (cron) is unchanged. See Durable background jobs for the replacement wiring.

// @noverify
import {
  BullMQOutboxSignal,
  BullMQQueueAdapter,
  BullMQScheduleAdapter,
  BullMQWorkerAdapter,
} from "@stndrds/adapter-bullmq";
import { E2BSandboxProvider } from "@stndrds/adapter-e2b";
import { MeilisearchSearchAdapter } from "@stndrds/adapter-meilisearch";
import {
  API_KEY_AUTH_CONTEXT,
  SchemaModule as StandardSchemaModule,
} from "@stndrds/adapter-nestjs";
import { RedisCacheAdapter, RedisRealtimeTransport } from "@stndrds/adapter-redis";
import { SupabaseDatabaseAdapter } from "@stndrds/adapter-supabase";
import { formRegistry, registry, viewRegistry } from "@stndrds/schema";
import type { ApiKeyAuthContext } from "@stndrds/schema";
import type { User } from "@supabase/supabase-js";
import { SupabaseClient } from "@supabase/supabase-js";
import { Queue } from "bullmq";
import type { Database } from "@my-app/supabase"; // generated types

const redisUrl = process.env.REDIS_URL ?? "redis://localhost:6379";
const parsedRedis = new URL(redisUrl);
const redisConnection = {
  host: parsedRedis.hostname,
  port: Number(parsedRedis.port) || 6379,
  ...(parsedRedis.password ? { password: parsedRedis.password } : {}),
};

const agentQueue = new Queue("agent-runs", { connection: redisConnection });
const eventQueue = new Queue("event-processing", { connection: redisConnection });

const supabase = new SupabaseClient<Database>(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!,
);

const searchAdapter = new MeilisearchSearchAdapter({
  host: process.env.MEILISEARCH_HOST ?? "",
  apiKey: process.env.MEILISEARCH_API_KEY ?? "",
  indexPrefix: "myapp",
});

const DEFAULT_TENANT_ID = "00000000-0000-0000-0000-000000000000";

export const SchemaModule = StandardSchemaModule.forRoot({
  // Database adapter — search is required when Meilisearch is enabled
  adapter: new SupabaseDatabaseAdapter(supabase, {
    search: searchAdapter,
  }),
  search: {
    reconcileOnStartup: true,
    reconcileIntervalMs: 2 * 60 * 60 * 1000,
    fullReindexThreshold: 0.2,
  },
  events: {
    enabled: true,
    outboxSignal: new BullMQOutboxSignal(eventQueue),
  },
  cache: {
    adapter: new RedisCacheAdapter({
      url: redisUrl,
      keyPrefix: "myapp:",
    }),
  },
  registry,
  viewRegistry,
  formRegistry,
  autoSync: true,
  auth: {
    requireUser: false,
    resolveUserId(request: Request & { user?: User }) {
      if (request.user?.id !== undefined) return request.user.id;
      const apiKeyCtx = (request as unknown as Record<string | symbol, unknown>)[
        API_KEY_AUTH_CONTEXT
      ] as ApiKeyAuthContext | undefined;
      return apiKeyCtx?.userId ?? apiKeyCtx?.createdBy ?? null;
    },
  },
  tenant: {
    mode: "multi",
    headerName: "X-Tenant-ID",
    defaultTenantId: DEFAULT_TENANT_ID,
    masterTenantId: DEFAULT_TENANT_ID,
  },
  objects: true,
  queue: new BullMQQueueAdapter(agentQueue),
  schedule: new BullMQScheduleAdapter(agentQueue),
  agents: {
    enabled: true,
    agentWorker: new BullMQWorkerAdapter({
      queueName: "agent-runs",
      connection: redisConnection,
      concurrency: 5,
    }),
    eventWorker: new BullMQWorkerAdapter({
      queueName: "event-processing",
      connection: redisConnection,
      concurrency: 10,
    }),
    streamTransport: new RedisRealtimeTransport(redisUrl),
    sandbox: {
      provider: new E2BSandboxProvider(),
      template: "stndrds-sandbox",
    },
  },
  global: true,
});

Module imports

Add SchemaModule alongside these standard modules in your AppModule:

  • ConfigModule.forRoot — loads .env / .env.local and makes ConfigService available globally. Pass validate to fail-fast on missing vars.
  • SupabaseModule.forRootAsync (from nestjs-supabase-js) — injects a typed SupabaseClient into the DI container, driven by ConfigService.
  • ThrottlerModule.forRoot — rate-limits all routes. Pair with ThrottlerGuard in APP_GUARD.
  • SchemaModule (your export from above) — global; provides all standards services, object providers, and auto-generated CRUD routes.
  • TenantModule — resolves X-Tenant-ID header into the AsyncLocalStorage tenant context via TenantResolverMiddleware.

Bulk routes (POST /records/:objectName/bulk-create, bulk-update, bulk-delete) need bootstrap tuning. Two things to plan for: (1) NestFactory's default 1 MB body parser rejects large payloads well below the 10,000-item DTO cap — bootstrap with bodyParser: false and register an explicit larger json/urlencoded limit (the sandbox uses 16mb); (2) a dedicated bulk throttle category limits these routes to 10 requests/minute. bulk-create returns { created, errors } and never fails the whole batch on a partial error — invalid items come back in errors keyed by their original index while valid siblings are created. Lifecycle hooks (@BeforeCreate / @AfterCreate and their delete counterparts) do not run on the bulk paths.

Auth guard

standards exposes ApiKeyAuthGuard from @stndrds/adapter-nestjs. Combine it with a Supabase JWT guard that extends BaseSupabaseAuthGuard (from nestjs-supabase-js):

// @noverify
import { Injectable, CanActivate, ExecutionContext } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { IS_PUBLIC_KEY } from "@stndrds/adapter-nestjs";
import { BaseSupabaseAuthGuard } from "nestjs-supabase-js";
import { SupabaseClient } from "@supabase/supabase-js";

@Injectable()
export class SupabaseGuard extends BaseSupabaseAuthGuard implements CanActivate {
  constructor(
    supabaseClient: SupabaseClient,
    private readonly reflector: Reflector,
  ) {
    super(supabaseClient);
  }

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
      context.getHandler(),
      context.getClass(),
    ]);
    if (isPublic) return true;
    return super.canActivate(context);
  }
}

Register both guards as APP_GUARD providers in AppModule. ApiKeyAuthGuard resolves last and populates API_KEY_AUTH_CONTEXT on the request; SupabaseGuard runs first and attaches request.user. The resolveUserId callback in SchemaModule.forRoot reads from whichever is present.

The standards-platform implementation lives in apps/api/src/modules/auth/supabase.guard.ts.

For projects that also expose the realtime gateway (websockets), the adapter now ships a unified TokenValidator contract under @stndrds/adapter-nestjs/auth — implement TokenValidator.validate(ctx) once and bind it to the TOKEN_VALIDATOR token. TokenAuthGuard uses it for HTTP requests, and RealtimeGateway calls the same validator during the WebSocket handshake, bridging userId, actorId, and tenantId into the request context. Pair with the optional realtime: { liveEventStore, channelAuthorizer } config on SchemaModule.forRoot and SchemaRealtimeModule to push live patches to subscribed clients.

When you omit channelAuthorizer, SchemaRealtimeModule now installs DefaultTenantChannelAuthorizer (exported from @stndrds/adapter-nestjs) instead of allowing every authenticated subscription. It fails closed: a channel is subscribable only when its resource resolves inside the caller's tenant, and user: channels are further restricted to the matching user actor. Two consequences to plan for — api-key-authenticated sockets (which carry no userId) no longer receive user: channels, and single-tenant deployments no longer allow one user to subscribe to another user's channel. Supply a custom channelAuthorizer for looser or stricter product policies.

Env vars

VarRequiredDescription
SUPABASE_URLyesYour Supabase project URL
SUPABASE_SERVICE_ROLE_KEYyesService role key — never expose to client
MEILISEARCH_HOSTyes (for search)URL of your Meili instance
MEILISEARCH_API_KEYyes (for search)Admin key
REDIS_URLyes (for cache/queue)Redis connection string
E2B_API_KEYyes (for agents)E2B sandbox API key

For a minimal setup using only Supabase, follow the tutorial chapter 2. You can omit the BullMQ, Redis, Meilisearch, and E2B sections until you need those capabilities.

On this page