Multi-tenant example

Try it
Start your own copy of this example:
npm create @setu-ts my-app -- --example multi-tenant
cd my-app && npm install
npm start
The commands further down run inside a clone of the repository.

The MultiTenancyPlugin resolves a tenant from x-tenant-id; its memory store partitions each tenant’s notes.

pnpm --dir apps/multi-tenant run start
pnpm --dir apps/multi-tenant run smoke

The smoke check proves that tenant B cannot read tenant A’s write.

Read the example deep dive for the concept and source walkthrough.

Read, in order

src/modules/notes/index.ts

import type { IRouterApi } from '@setu-ts/common';
import { registerNotesRoutes } from './notes.controller.ts';

/** Registers the notes feature. */
export function registerNotesModule(router: IRouterApi): void {
  registerNotesRoutes(router);
}

src/modules/notes/notes.controller.ts

import type { IRouterApi } from '@setu-ts/common';
import { tenantNotes } from './notes.service.ts';

/** Registers the notes routes; each request reads and writes its own tenant's notes. */
export function registerNotesRoutes(router: IRouterApi): void {
  router.group('/notes', (routes) => {
    routes.post('/', async (ctx) => {
      const { text } = await ctx.request.json<{ text: string }>();
      return ctx.response.status(201).json(await tenantNotes(ctx).create({ text }));
    });

    routes.get('/', async (ctx) => ctx.response.json(await tenantNotes(ctx).findAll()));
  });
}

src/modules/notes/notes.service.ts

import { CAPABILITIES } from '@setu-ts/common';
import type { IMultiTenancyService, IRequestContext, ITenantRepository } from '@setu-ts/common';

/** A note, stored in the partition of the tenant that wrote it. */
export interface Note {
  readonly id: string;
  readonly text: string;
}

/** Resolves the notes repository for the tenant of the current request. */
export function tenantNotes(ctx: IRequestContext): ITenantRepository<Note> {
  return ctx.services
    .get<IMultiTenancyService>(CAPABILITIES.MULTI_TENANCY)
    .getRepository<Note>(ctx, 'notes');
}

smoke.ts

import assert from 'node:assert/strict';
import { after, before, describe, it } from 'node:test';
import type { IKernelApplication } from '@setu-ts/kernel';
import { createApp, TENANT_HEADER } from './setu.config.ts';

describe('multi-tenant example', () => {
  let app: IKernelApplication;

  before(async () => {
    app = createApp();
    await app.start();
  });

  after(async () => {
    await app.stop();
  });

  it("keeps one tenant's writes invisible to another", async () => {
    const created = await app.inject({
      method: 'POST',
      url: '/notes',
      headers: { [TENANT_HEADER]: 'tenant-a' },
      body: { text: 'only A can read this' },
    });
    assert.equal(created.statusCode, 201);

    const tenantA = await app.inject({
      method: 'GET',
      url: '/notes',
      headers: { [TENANT_HEADER]: 'tenant-a' },
    });
    const tenantB = await app.inject({
      method: 'GET',
      url: '/notes',
      headers: { [TENANT_HEADER]: 'tenant-b' },
    });

    assert.equal(tenantA.json<unknown[]>().length, 1);
    assert.equal(tenantB.json<unknown[]>().length, 0);
  });
});