Arkive

Phase 2 lesson 4 walkthrough: withApiGuard

As of
Version1.0.0
Kindguidance
Length3,732 words, about 16 min
Tagsbuildwalkthroughphase-2api-guardrlsapiauthenticationtesting

This guide reproduces lesson 4 of Phase 2 step by step: tasks 2.2 and 2.3, the wrapper every API route goes through. It ties together lesson 2's resolveApiCaller, lesson 3's getEnabledFeatures and withRLS. It assumes lesson 3 and its follow-ups are merged (lesson 3 walkthrough, including its post note).

The result shipped as two PRs: the shared test helpers in sidekick#18, then the guard in sidekick#19. The design is recorded in contracts (version 2.5.0).

Every step explains what you are doing and why. Follow the reasoning, not just the commands.


Step 1 — Understand the Wrapper and the Order of Checks

What and why: The proxy deliberately lets /api through, so every route must authenticate itself. Doing that in each handler would scatter security across dozens of files, and one forgotten check would be a hole. A higher-order function puts it in one place: withApiGuard(handler) returns a new function that runs the checks, then calls your handler only for a permitted caller. It's the same idea as React's higher-order components (connect(Component)).

  1. Read packages/eslint-plugin-sidekick/src/rules/require-api-guard.ts. It forces every App Router route.* file to export each HTTP method (GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS) as export const METHOD = withApiGuard(handler, options), with withApiGuard imported. Re-exports like export { handler as GET } are rejected because the rule can't see what handler is. The import check stops a local look-alike named withApiGuard from passing. Each method gets its own call and options, so a route can be stricter for POST than for GET:
TypeScript
// apps/web/src/app/api/notes/route.ts (example)
export const GET = withApiGuard(listNotes, { feature: 'core-drive', requireScope: 'notes:read' });
export const POST = withApiGuard(createNote, { feature: 'core-drive', requireScope: 'notes:write' });
  1. Settle the order. The checks run from cheapest to most expensive, and a request stops at the first failure:
#CheckNeeds the database?Fails with
1resolveApiCaller(req)No (one call to Supabase Auth)401 UNAUTHENTICATED
2Scope, for key callers onlyNo (in memory)403 SCOPE_MISSING
3withRLS(caller.userId, …) opens the transactionYes
4Feature, inside the transactionYes, under RLS403 FEATURE_DISABLED
5The handler, in the same transactionYeswhatever it returns
6Anything throws500 INTERNAL
  • Scope before the transaction: it needs no database, so a key without the scope never takes a connection from Supavisor's small, shared pool.
  • Feature inside the transaction: user_feature_entitlements is RLS-protected, so getEnabledFeatures(tx) only sees the caller's rows once withRLS has set app.current_user_id. Checking outside would need a second transaction, which means a second pooled connection on every request.
  • This reorders the sketch in With API guard, which checked features first, by userId, without RLS.
  1. Branch from an up-to-date main:
Shell
git switch main
git pull origin main
git switch -c feature/P2L4-api-guard

Step 2 — Share the Test Helpers

What and why: The guard's tests need a request builder, mock callers and database clients that auth.test.ts and the integration tests already define, each with its own copy of requireEnv. Move them into packages/core/tests/helpers/ first. This went in as its own PR, sidekick#18, so the guard PR stays focused.

Name everything by its full qualifier (coding standard 6): MOCK_… for mock values, createMock… for mock factories, mock… for local mocks, …Spy for spies and test…Db for test connections. The name says what a thing is wherever it's read, even inside mocks.ts.

  1. Create packages/core/tests/helpers/db.ts:
TypeScript
import postgres, { type Sql } from 'postgres';

// Connections to the throwaway database tests/with-test-db.sh builds from our migrations.
// Each factory opens a new single-connection client; the test file that creates it ends it
// in afterAll, so every file owns its connections' lifecycle.

/** Reads a connection URL tests/with-test-db.sh exports; fails loudly when run outside it */
export function requireTestEnv(name: string): string {
  const value = process.env[name];
  if (!value) {
    throw new Error(`${name} is not set; run this test through tests/with-test-db.sh`);
  }

  return value;
}

/** supabase_auth_admin: creates and deletes users the way Supabase Auth does */
export function createTestAuthDb(): Sql {
  return postgres(requireTestEnv('TEST_AUTH_DATABASE_URL'), { max: 1 });
}

/** postgres: the privileged migration/seed role, the only one that writes entitlements */
export function createTestAdminDb(): Sql {
  return postgres(requireTestEnv('TEST_ADMIN_DATABASE_URL'), { max: 1 });
}

/** app_runtime: the role withRLS uses for every user request */
export function createTestRuntimeDb(): Sql {
  return postgres(requireTestEnv('DATABASE_URL'), { max: 1 });
}

Factories, not shared clients: each test file opens the connections it needs and ends them in its own afterAll. A client exported ready-made would have no clear owner, and an open connection keeps the test process from exiting. Exported functions declare their return types (Sql), which explicit-module-boundary-types requires.

  1. Create packages/core/tests/helpers/http.ts:
TypeScript
/** A Request to a fixed test URL, optionally carrying headers such as Authorization */
export function createMockHttpRequest(headers?: HeadersInit): Request {
  return new Request('http://localhost/api/test', { headers });
}
  1. Create packages/core/tests/helpers/mocks.ts:
TypeScript
import {
  AuthError,
  AuthSessionMissingError,
  type SupabaseClient,
  type User,
  type UserResponse,
} from '@supabase/supabase-js';

import type { KeyBasedApiCaller, SessionBasedApiCaller } from '../../src/api/auth';
import type { RlsTx } from '../../src/db';

// Shared mock values. Every export carries its full qualifier (MOCK_…, createMock…), so a
// reader of any test file can tell a mock from real code without opening this file.

export const MOCK_AUTH_TOKEN = 'bearer mocked-auth-token';

export const MOCK_USER = { id: 'mock-user-id' } as User;

export const MOCK_SESSION_CALLER: SessionBasedApiCaller = { kind: 'session', userId: MOCK_USER.id };

export const MOCK_KEY_CALLER: KeyBasedApiCaller = {
  kind: 'key',
  userId: MOCK_USER.id,
  keyId: 'mock-key-id',
  scopes: ['notes:read'],
};

/** Stands in for withRLS's transaction where the code under test only passes it along */
export const MOCK_RLS_TX = {} as RlsTx;

export const MOCK_AUTH_USER_RESPONSE: {
  validUser: UserResponse;
  invalidUser: UserResponse;
  error: UserResponse;
} = {
  validUser: { data: { user: MOCK_USER }, error: null },
  invalidUser: { data: { user: null }, error: new AuthSessionMissingError() },
  error: { data: { user: null }, error: new AuthError('boom') },
};

/** A Supabase server client whose auth.getUser() resolves to one of the responses above */
export function createMockSupabaseServerClient(
  state: keyof typeof MOCK_AUTH_USER_RESPONSE,
): SupabaseClient {
  return {
    auth: {
      getUser: async () => MOCK_AUTH_USER_RESPONSE[state],
    },
  } as unknown as SupabaseClient;
}

MOCK_SESSION_CALLER.userId is MOCK_USER.id, so the resolveApiCaller and guard tests describe the same user. MOCK_KEY_CALLER and MOCK_RLS_TX are used by the guard tests in steps 5 and 6.

  1. Point the existing tests at the helpers:
    • src/api/auth.test.ts: delete its local mocks and getHttpRequestObject. Import createMockHttpRequest, MOCK_AUTH_TOKEN, MOCK_SESSION_CALLER and createMockSupabaseServerClient. getHttpRequestObject(false) becomes createMockHttpRequest(), (true) becomes createMockHttpRequest({ Authorization: MOCK_AUTH_TOKEN }), (true, '') becomes createMockHttpRequest({ Authorization: '' }), and the session assertion becomes toEqual(MOCK_SESSION_CALLER).
    • tests/integration/rls.test.ts, entitlements.test.ts and data-api-roles.test.ts: delete requireEnv and the postgres import. Replace each postgres(requireEnv(…), { max: 1 }) with the matching factory, and rename authDb, adminDb and runtimeDb to testAuthDb, testAdminDb and testRuntimeDb.

The vi.mock(…) calls stay in each test file. Vitest hoists them above the imports and replaces modules per file, so a helper can't register mocks for a test.

The helpers stay inside packages/core until a second package needs them. Then they move to a @sidekick/test-utils package. A package now would cost its own package.json, tsconfig, Turbo tasks and boundaries rules for three small files.


Step 3 — Write the Guard

What and why: withApiGuard returns the function Next.js calls on each request, (req, ctx) => Promise<Response>. Your handler receives the RLS transaction, the caller, the request and Next.js's context.

  1. Create packages/core/src/api/guard.ts:
TypeScript
import 'server-only';

import { withRLS, type RlsTx } from '../db';
import { resolveApiCaller, type ApiCaller } from './auth';
import { getEnabledFeatures } from '../features/entitlements';

export interface GuardOptions {
  /** Key of the feature the caller must be entitled to */
  feature?: string;
  /** Scope an API-key caller must hold; session callers skip this check */
  requireScope?: string;
}

export interface ApiHandlerArgs<Ctx> {
  tx: RlsTx;
  caller: ApiCaller;
  req: Request;
  /** Next.js's second route-handler argument (e.g. `{ params }`), passed through untouched */
  ctx: Ctx;
}

export type ApiHandler<Ctx> = (args: ApiHandlerArgs<Ctx>) => Promise<Response>;

/** The error-shape table from contracts.md: one place maps a code to its status and message */
const GUARD_ERRORS = {
  UNAUTHENTICATED: { status: 401, error: 'Unauthorized' },
  SCOPE_MISSING: { status: 403, error: 'Forbidden' },
  FEATURE_DISABLED: { status: 403, error: 'Feature disabled' },
  INTERNAL: { status: 500, error: 'Internal error' },
} as const;

type GuardErrorCode = keyof typeof GUARD_ERRORS;

function guardError(code: GuardErrorCode): Response {
  const { status, error } = GUARD_ERRORS[code];

  return Response.json({ error, code }, { status });
}

/** Scopes only narrow what an API key may do; a session caller has no scopes to check */
function isMissingScope(caller: ApiCaller, scope?: string): boolean {
  return caller.kind === 'key' && scope !== undefined && !caller.scopes.includes(scope);
}

/**
 * Wraps a route handler with authentication, scope and feature checks, and runs it
 * inside one RLS transaction for the caller. Every App Router route handler must use it.
 * @param handler Runs only for an authenticated, permitted caller
 * @param opts Feature the caller must be entitled to, and scope a key caller must hold
 * @returns A Next.js route handler
 * @description
 * - It runs from cheapest to most expensive
 * - A request stops at the first failure
 */
export function withApiGuard<Ctx = unknown>(
  handler: ApiHandler<Ctx>,
  opts: GuardOptions = {},
): (req: Request, ctx: Ctx) => Promise<Response> {
  return async function apiGuard(req, ctx) {
    try {
      const caller = await resolveApiCaller(req);
      if (!caller) {
        return guardError('UNAUTHENTICATED');
      }

      // Needs no database, so it runs before a transaction (and a pooled connection) is taken
      if (isMissingScope(caller, opts.requireScope)) {
        return guardError('SCOPE_MISSING');
      }

      // `return await`, not `return`: the try/catch only catches a rejection it awaits.
      // Without it, a rejection skips our catch: no log, and no { error, code } body.
      return await withRLS(caller.userId, async (tx) => {
        if (opts.feature && !(await getEnabledFeatures(tx)).includes(opts.feature)) {
          return guardError('FEATURE_DISABLED');
        }

        return handler({ tx, caller, req, ctx });
      });
    } catch (error) {
      // withRLS has already rolled the transaction back. Lesson 5 replaces this with api.error.
      console.error('withApiGuard: unhandled error', error);
      return guardError('INTERNAL');
    }
  };
}

What each part does:

  • Ctx is a generic. Next.js passes route handlers a second argument whose shape depends on the route ({ params: Promise<{ id: string }> } for /api/notes/[id]). The guard only passes it through, so withApiGuard<Ctx> lets TypeScript infer it from your handler and keep ctx.params typed. Ctx = unknown is the default for routes that ignore it.
  • caller, not userId. Handlers read caller.userId. After if (!caller) return …, TypeScript narrows caller from ApiCaller | undefined to ApiCaller, so the handler never sees undefined. Passing userId too would be a second copy of the same value.
  • ApiHandlerArgs is a named interface, because object shapes are interfaces (lesson 1's rule), and a handler written apart from its route can annotate its argument with it.
  • feature is optional. Some routes belong to no feature: /api/health, account settings, "which features do I have?". When it's unset, the guard skips the entitlements query entirely. && short-circuits, so await getEnabledFeatures(tx) never runs. A forgotten feature can't leak another user's data, because auth and RLS still protect that.
  • The error table. GUARD_ERRORS … as const makes the codes literal types, and keyof typeof turns them into the GuardErrorCode union, so a typo'd code doesn't compile. It's the only place a status is paired with a message, so every guard error has the same { error, code } shape (Error handling). The two 403s keep distinct codes. Vague errors protect unauthenticated surfaces, like a login form that won't say which emails exist. This caller is signed in and asking about their own account, and the UI needs to tell "enable this feature" from "this key needs notes:write". Detailed reasons go to the server log in lesson 5.
  • Rollback and return await. Drizzle's transaction() commits when the callback resolves and rolls back when it throws. So a handler that throws halfway through a write leaves nothing behind. The catch logs the real error server-side and sends only "Internal error". return await matters: a plain return withRLS(…) hands back the promise without awaiting it, so a rejection would skip the catch. Next.js would then answer with its own bare 500, with no log and no { error, code } body. The try also covers resolveApiCaller, so a missing Supabase env var becomes a logged 500.
  • A returned error keeps the write. Only a throw rolls back. A handler that writes and then returns a 400 commits. Handlers validate before they write, or throw to undo. Step 6 pins this down in a test.
  • A named inner function (apiGuard) shows up by name in stack traces.

Step 4 — Export It

  1. Create packages/core/src/api/index.ts:
TypeScript
// Public API surface: route handlers import withApiGuard from @sidekick/core/api.
// resolveApiCaller stays internal; only the guard calls it.
export { type ApiHandler, type ApiHandlerArgs, type GuardOptions, withApiGuard } from './guard';
export type { ApiCaller } from './auth';
  1. Add the entry to exports in packages/core/package.json, after ".":
JSON
"./api": "./src/api/index.ts",

Routes import from @sidekick/core/api. resolveApiCaller isn't exported, because only the guard should call it.


Step 5 — Unit Tests

What and why: A unit test checks the guard's own decisions: which check runs, in what order, and what comes back. Its three collaborators are mocked, so the tests need no database, no Supabase and no env vars.

  1. Create packages/core/src/api/guard.test.ts:
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest';

import { createMockHttpRequest } from '../../tests/helpers/http';
import { MOCK_KEY_CALLER, MOCK_RLS_TX, MOCK_SESSION_CALLER } from '../../tests/helpers/mocks';
import { withRLS } from '../db';
import { getEnabledFeatures } from '../features/entitlements';
import { resolveApiCaller } from './auth';
import { type ApiHandler, withApiGuard } from './guard';

vi.mock('./auth', () => ({ resolveApiCaller: vi.fn() }));
vi.mock('../db', () => ({ withRLS: vi.fn() }));
vi.mock('../features/entitlements', () => ({ getEnabledFeatures: vi.fn() }));

const mockHandler = vi.fn<ApiHandler<unknown>>();

describe('withApiGuard', () => {
  beforeEach(() => {
    vi.resetAllMocks();
    vi.mocked(resolveApiCaller).mockResolvedValue(MOCK_SESSION_CALLER);
    // Run the callback the way withRLS does, minus the database
    vi.mocked(withRLS).mockImplementation(async (_userId, fn) => fn(MOCK_RLS_TX));
    vi.mocked(getEnabledFeatures).mockResolvedValue(['core-drive']);
    mockHandler.mockResolvedValue(Response.json({ ok: true }));
  });

  it('responds 401 without opening a transaction when there is no caller', async () => {
    vi.mocked(resolveApiCaller).mockResolvedValue(undefined);
    const response = await withApiGuard(mockHandler)(createMockHttpRequest(), undefined);

    expect(response.status).toBe(401);
    expect(await response.json()).toEqual({ error: 'Unauthorized', code: 'UNAUTHENTICATED' });
    expect(withRLS).not.toHaveBeenCalled();
    expect(mockHandler).not.toHaveBeenCalled();
  });

  it('responds 403 without opening a transaction when a key caller lacks the scope', async () => {
    vi.mocked(resolveApiCaller).mockResolvedValue(MOCK_KEY_CALLER);
    const guarded = withApiGuard(mockHandler, { requireScope: 'notes:write' });
    const response = await guarded(createMockHttpRequest(), undefined);

    expect(response.status).toBe(403);
    expect(await response.json()).toEqual({ error: 'Forbidden', code: 'SCOPE_MISSING' });
    expect(withRLS).not.toHaveBeenCalled();
    expect(mockHandler).not.toHaveBeenCalled();
  });

  it('lets a key caller with the scope through', async () => {
    vi.mocked(resolveApiCaller).mockResolvedValue(MOCK_KEY_CALLER);
    const guarded = withApiGuard(mockHandler, { requireScope: 'notes:read' });
    const response = await guarded(createMockHttpRequest(), undefined);

    expect(response.status).toBe(200);
    expect(mockHandler).toHaveBeenCalledOnce();
  });

  it('skips the scope check for session callers', async () => {
    const guarded = withApiGuard(mockHandler, { requireScope: 'notes:write' });
    const response = await guarded(createMockHttpRequest(), undefined);

    expect(response.status).toBe(200);
    expect(mockHandler).toHaveBeenCalledOnce();
  });

  it('responds 403 when the caller is not entitled to the feature', async () => {
    const guarded = withApiGuard(mockHandler, { feature: 'taxila' });
    const response = await guarded(createMockHttpRequest(), undefined);

    expect(response.status).toBe(403);
    expect(await response.json()).toEqual({ error: 'Feature disabled', code: 'FEATURE_DISABLED' });
    expect(getEnabledFeatures).toHaveBeenCalledWith(MOCK_RLS_TX);
    expect(mockHandler).not.toHaveBeenCalled();
  });

  it('does not read entitlements when no feature is required', async () => {
    const response = await withApiGuard(mockHandler)(createMockHttpRequest(), undefined);

    expect(response.status).toBe(200);
    expect(getEnabledFeatures).not.toHaveBeenCalled();
  });

  it('runs the handler in the caller’s transaction and passes ctx through', async () => {
    const mockRequest = createMockHttpRequest();
    const mockCtx = { params: Promise.resolve({ id: 'note-1' }) };
    const guarded = withApiGuard(mockHandler, { feature: 'core-drive' });
    const response = await guarded(mockRequest, mockCtx);

    expect(withRLS).toHaveBeenCalledWith(MOCK_SESSION_CALLER.userId, expect.any(Function));
    expect(mockHandler).toHaveBeenCalledWith({
      tx: MOCK_RLS_TX,
      caller: MOCK_SESSION_CALLER,
      req: mockRequest,
      ctx: mockCtx,
    });
    expect(await response.json()).toEqual({ ok: true });
  });

  it.each([
    [
      'the handler',
      () => mockHandler.mockRejectedValue(new Error('relation "notes" does not exist')),
    ],
    ['resolveApiCaller', () => vi.mocked(resolveApiCaller).mockRejectedValue(new Error('no env'))],
  ])('turns a throw in %s into a generic 500', async (_where, makeItThrow) => {
    makeItThrow();
    const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
    const response = await withApiGuard(mockHandler)(createMockHttpRequest(), undefined);

    expect(response.status).toBe(500);
    // Exactly the generic body: no message, stack or SQL reaches the client
    expect(await response.json()).toEqual({ error: 'Internal error', code: 'INTERNAL' });
    expect(consoleErrorSpy).toHaveBeenCalledOnce();
  });
});
  • Mock paths match the imports. vi.mock replaces a module, so the path must be the one guard.ts imports: '../db', not '../db/rls' as the brief said. Vitest hoists the mocks above the imports, so the real auth.ts (which needs Next.js cookies) and the real db (which throws without DATABASE_URL) never load.
  • The withRLS mock runs its callback with MOCK_RLS_TX, like the real one without a database. A fixed return value would skip the feature check and the handler. The tests check that the same object reaches getEnabledFeatures and the handler, which proves they share one transaction.
  • Negative assertions prove the order. withRLS not called on a 401 or a missing scope, and getEnabledFeatures not called without a feature. "Cheap checks first" is a promise only not.toHaveBeenCalled() can prove.
  • toEqual on the whole body pins the error table, and in the 500 case proves the thrown message (relation "notes" does not exist) doesn't leak.
  1. Run them:
Shell
pnpm --filter @sidekick/core test

Expect 15 passed: 9 here and 6 in auth.test.ts.


Step 6 — Integration Test

What and why: The unit tests mocked exactly the things that matter most: that RLS isolates users, that entitlements come from the table, and that a throw rolls back. Only a real Postgres proves those. This test mocks only resolveApiCaller, because there's no Supabase Auth in the throwaway database.

  1. Create packages/core/tests/integration/guard.test.ts:
TypeScript
import { randomUUID } from 'node:crypto';
import { eq } from 'drizzle-orm';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';

import { withApiGuard } from '../../src/api';
import { resolveApiCaller } from '../../src/api/auth';
import { profiles } from '../../src/db/schema';
import { createTestAdminDb, createTestAuthDb } from '../helpers/db';
import { createMockHttpRequest } from '../helpers/http';

// Proves the guard end to end on a database built from our migrations. Only the caller
// is mocked (there is no Supabase Auth here); withRLS, the RLS policies, entitlements,
// commit and rollback all run for real. Runs inside tests/with-test-db.sh.
vi.mock('../../src/api/auth', () => ({ resolveApiCaller: vi.fn() }));

const testAuthDb = createTestAuthDb();
const testAdminDb = createTestAdminDb();

const entitledUserId = randomUUID();
const otherUserId = randomUUID();

function mockSignedInAs(userId: string) {
  vi.mocked(resolveApiCaller).mockResolvedValue({ kind: 'session', userId });
}

// Reads as postgres, which bypasses RLS, so the check doesn't depend on the code under test
async function getProfileEmail(userId: string) {
  const [row] = await testAdminDb`SELECT email FROM profiles WHERE id = ${userId}`;
  return row.email;
}

beforeAll(async () => {
  // The 0005 trigger creates each user's profile
  await testAuthDb`INSERT INTO auth.users ${testAuthDb(
    [
      { id: entitledUserId, email: '[email protected]' },
      { id: otherUserId, email: '[email protected]' },
    ],
    'id',
    'email',
  )}`;

  await testAdminDb`INSERT INTO user_feature_entitlements (user_id, feature_key)
    VALUES (${entitledUserId}, 'core-drive')`;
});

afterAll(async () => {
  await testAuthDb.end();
  await testAdminDb.end();
});

beforeEach(() => {
  vi.resetAllMocks();
});

describe('withApiGuard on a real database', () => {
  it('lets an entitled user reach a feature route', async () => {
    mockSignedInAs(entitledUserId);
    const guarded = withApiGuard(async () => Response.json({ ok: true }), {
      feature: 'core-drive',
    });

    const response = await guarded(createMockHttpRequest(), undefined);
    expect(response.status).toBe(200);
  });

  it('responds 403 to a user who is not entitled to the feature', async () => {
    mockSignedInAs(otherUserId);
    const mockHandler = vi.fn(async () => Response.json({ ok: true }));
    const guarded = withApiGuard(mockHandler, { feature: 'core-drive' });

    const response = await guarded(createMockHttpRequest(), undefined);
    expect(response.status).toBe(403);
    expect(await response.json()).toEqual({ error: 'Feature disabled', code: 'FEATURE_DISABLED' });
    expect(mockHandler).not.toHaveBeenCalled();
  });

  it('gives the handler a transaction that sees only the caller’s profile', async () => {
    mockSignedInAs(entitledUserId);
    const guarded = withApiGuard(async ({ tx }) =>
      Response.json(await tx.select({ id: profiles.id }).from(profiles)),
    );

    const response = await guarded(createMockHttpRequest(), undefined);
    expect(await response.json()).toEqual([{ id: entitledUserId }]);
  });

  it('rolls back the handler’s write when it throws', async () => {
    mockSignedInAs(entitledUserId);
    vi.spyOn(console, 'error').mockImplementation(() => {});
    const guarded = withApiGuard(async ({ tx, caller }) => {
      await tx
        .update(profiles)
        .set({ email: '[email protected]' })
        .where(eq(profiles.id, caller.userId));
      throw new Error('failed after the write');
    });

    const response = await guarded(createMockHttpRequest(), undefined);
    expect(response.status).toBe(500);
    expect(await getProfileEmail(entitledUserId)).toBe('[email protected]');
  });

  it('keeps the handler’s write when it returns an error response instead of throwing', async () => {
    mockSignedInAs(otherUserId);
    const guarded = withApiGuard(async ({ tx, caller }) => {
      await tx
        .update(profiles)
        .set({ email: '[email protected]' })
        .where(eq(profiles.id, caller.userId));
      return Response.json({ error: 'Invalid input', code: 'VALIDATION_ERROR' }, { status: 400 });
    });

    const response = await guarded(createMockHttpRequest(), undefined);
    expect(response.status).toBe(400);
    // Only a throw rolls back: handlers must validate before they write
    expect(await getProfileEmail(otherUserId)).toBe('[email protected]');
  });
});
TestWhat it proves
entitled user reaches a feature routethe real entitlements table drives the feature check
non-entitled user gets 403same, and the handler never runs
handler sees only the caller's profilethe tx it receives carries the caller's RLS context: a query with no WHERE returns one row
a throw rolls back the writethe email is read back through postgres, which bypasses RLS, so the check doesn't rely on the code under test
a returned 400 keeps the writeDrizzle commits whenever the callback resolves

All integration files share one database and Vitest runs them in parallel, so each file creates its own users with randomUUID(). Fixed ids shared across files would collide on insert, and the files would see each other's deletes and edits. Each test touches a different user's email, so the order within the file doesn't matter either.

  1. Run it:
Shell
pnpm --filter @sidekick/core test:integration

Expect 17 passed: 5 here, plus rls, entitlements and data-api-roles.

  1. Break it on purpose. In guard.ts, change the feature line to if (false) {. Exactly one test fails: responds 403 to a user who is not entitled to the feature. Put the line back and run again.

Step 7 — Verify Like CI and Ship

  1. Run everything uncached:
Shell
pnpm prettier:check
pnpm turbo lint typecheck build test --force
pnpm --filter @sidekick/core test:integration
  1. Commit, push and open the PR. If the helpers from step 2 are their own PR that isn't merged yet, stack the guard on it with --base chore/core-test-helpers. After the helpers PR is squash-merged, rebase onto main so the guard PR shows only its own changes:
Shell
git add packages/core/package.json packages/core/src/api packages/core/tests/integration/guard.test.ts
git commit -m "feat(core): add withApiGuard with RLS transaction and entitlement check"
git fetch origin main
git rebase --onto origin/main chore/core-test-helpers feature/P2L4-api-guard
git push -u origin feature/P2L4-api-guard --force-with-lease
gh pr create --base main --fill

Wait for the checks job, then squash-merge.


What you have now

  • withApiGuard(handler, opts) in packages/core/src/api/guard.ts, exported from @sidekick/core/api. It authenticates, checks scope before taking a connection, opens one RLS transaction per request, checks the feature inside it, and turns any throw into a rolled-back, logged, generic 500.
  • Shared test helpers in packages/core/tests/helpers/.
  • Nine unit tests for the guard's decisions and five integration tests on a real database, one of them proven to fail when the feature check is removed.

Next: lesson 5, structured logging inside the guard.

Arkive

Index

Notes, decisions, and progress on my journey as I build Project Sidekick — built in the open; written as it happens.

Tags (145)

View

228 entries.

As ofTitleKind
2026-10-23Phase 2 lesson 3 walkthrough: entitlementsbuild walkthrough phase-2 feature-system database drizzle rls postgresql testingguidance
2026-10-23Phase 2 lesson 4 walkthrough: withApiGuardReadingbuild walkthrough phase-2 api-guard rls api authentication testingguidance
2026-10-23Phase 2 lesson 2 walkthrough: resolveApiCallerbuild walkthrough phase-2 api-guard authentication supabase testingguidance
2026-10-23Lesson 3 (tasks 2.8, 2.9): entitlements table and getEnabledFeaturesplan phase-2 task feature-system database drizzle rls postgresql testingplan
2026-10-23Lesson 4 (tasks 2.2, 2.3): withApiGuard with withRLSplan phase-2 task api-guard rls api security testingplan
2026-10-23Phase 2 build rules and target designplan phase-2 api-guard rls api authentication typescript guidanceplan
2026-10-23Pre-2 walkthroughbuild walkthrough pre-2 rls supabase drizzle postgresql testing eslintguidance
2026-10-23Agent guideai-coding agent gateway agent-guidance rls supabase drizzle api react prettierguidance
2026-10-23Database and securityai-coding database security agent-guidance api-guard rls drizzle api react repository-patternguidance
2026-10-23Error handlingsystem-design observability api-guard rls supabase api nextjs react aispec
2026-10-23Phase 1.1 DB RLS enforcementplan phase-1.1 rls database supabase drizzle postgresql api typescriptguidance
2026-10-23Phase 2 core infrastructure API guard feature systemplan living-plan phase-2 api-guard rls api authentication feature-systemplan
2026-10-23Checkpoint B walkthroughbuild walkthrough phase-0 checkpointguidance
2026-10-20Lesson 2 (task 2.1): resolveApiCallerplan phase-2 task api-guard authentication supabase nextjsplan
2026-10-18Lesson 1 (tasks 2.6, 2.7): FeatureManifest and ALL_FEATURESplan phase-2 task feature-system typescript monorepoplan
2026-10-18Lesson 5 (tasks 2.4, 2.5): request and auth-failure loggingplan phase-2 task api-guard observability eslintplan
2026-10-18Lesson 6 (tasks 2.11, 2.12): /api/health and guard verificationplan phase-2 task api-guard api nextjs testingplan
2026-10-18Lesson 7 (task 2.10): dev entitlement seed scriptplan phase-2 task feature-system database postgresqlplan
2026-10-18Phase 2 lesson 1 walkthrough: feature registrybuild walkthrough phase-2 feature-system typescript monorepo testingguidance
2026-10-18Backlogged unplannedplan living-plan backlog feature-system taxila zinsser alter-ego parrot rls supabaseplan
2026-10-18Dependency flowsystem-design monorepo clispec
2026-10-18Registrysystem-design feature-systemspec
2026-10-17Core Drive to be Practical and Grounded in Realitysystem-design feature core-drive prdprd
2026-10-17MVP Scopeprd mvp specification core-drive taxila war-room factory alter-ego zinsser parrot api cli native-appprd
2026-10-17An AI Writing Coach and an Appsystem-design prd feature zinsser aiprd
2026-10-17Overview of Taxilasystem-design prd feature taxila ragprd
2026-10-16Best practice rulessystem-design code-quality-check eslintspec
2026-10-16ESLint best practice rulesdecision technical architecture-decision eslint typescript nextjsdecision
2026-10-16Stay on ESLint revisit oxlintdecision technical architecture-decision eslint typescriptdecision
2026-10-16Pre 2 foundation hardening completeplan pre-2 foundation security database testingplan
2026-10-16ESLint plugin boundariessystem-design code-quality-check eslintspec
2026-10-16Living implementation planplan living-plan taxila zinsser alter-ego war-room factory parrot core-driveplan
2026-10-16Portable documentation vaultopportunity documentation agentic-coding portability severity-mediumopportunity
2026-10-16Prettier at repo levelsystem-design code-quality-check prettierspec
2026-10-16Server actionssystem-design nextjs rls api authenticationspec
2026-10-16With API guardsystem-design security api-guard rls api authentication typescriptspec
2026-10-15Pooler client configurationopportunity phase-2 database supabase drizzle severity-mediumopportunity
2026-10-14Vitestdecision technical architecture-decision testing vitest monorepodecision
2026-10-14App runtime role for Drizzledecision technical architecture-decision rls supabase drizzledecision
2026-10-14Profile creation Postgres triggersystem-design security postgresql api authenticationspec
2026-10-14Profiles schemasystem-design security rls authenticationspec
2026-10-14Tag taxonomyai-coding agent-guidance taxonomy discoveryguidance
2026-10-13Anatomy of a Core Drive Entrysystem-design prd feature core-driveprd
2026-10-13Core Drive Stores Personal and Sensitive Informationsystem-design prd feature core-drive taxila graphprd
2026-10-13Core Drive Stores Values Onlysystem-design prd feature core-drive war-room factoryprd
2026-10-13Core Drive to Foster Reflections and Learningssystem-design prd feature core-driveprd
2026-10-13Model Routingprd vercel ai embedding routingprd
2026-10-13Overview of Core Drivesystem-design prd feature core-drive factory ai alter-egoprd
2026-10-13Phase 0 — Foundation and Toolingplan phase-0 api nextjs typescript eslint turborepo pnpmplan
2026-10-13Source Attributionprd feature alter-ego aiprd
2026-10-12Command Paletteprd ux specificationprd
2026-10-12MVP Design Objectiveprd ux specificationprd
2026-10-12Be the Best Version of Yourselfprd value-proposition specificationprd
2026-10-12Make the Right Behavior Easierprd value-proposition specificationprd
2026-10-11Solve Problems and Build Thingsmotivation creativityjournal
2026-10-11A Personal Discoverymotivation discipline bucket-list ambition entrepreneurship sidekickjournal
2026-10-11Commercialize Sidekickmotivation finance freedomjournal
2026-10-10Realityprinciple reality pragmatism survivalguidance
2026-10-10Seek Closureprinciple productivity happiness purposeguidance
2026-10-08No Do-oversprinciple core-drive execution productivityguidance
2026-10-08Pragmatismprinciple practical pragmaticguidance
2026-10-04Protect Your Mind and Your Timeprinciple productivity automation minimalismguidance
2026-10-03Focus Funnelprinciple core-drive productivityguidance
2026-10-02Fuel Career Growthmotivation career ambitionjournal
2026-10-02Learnmotivation learning ai ragjournal
2026-10-02Coding Agent HeuristicsRetiredWhich AI model or coding agent to use for which kind of session based on the type of task, teaching value, and complexitysidekick project technical ai heuristicnote
2026-09-19A graph pattern not a graph databasesystem-design global-tagging-and-linking postgresql graphspec
2026-09-19Actual stateglossary terminologyglossary
2026-09-19Algorithm of lifeglossary terminology nextjsglossary
2026-09-19Alter egosystem-design prd feature taxila alter-ego factory aiprd
2026-09-19API firstsystem-design api clispec
2026-09-19API route fail openopportunity phase-2 api security eslint severity-lowopportunity
2026-09-19API versioningsystem-design apispec
2026-09-19App runtime for Drizzlesystem-design database drizzle postgresqlspec
2026-09-19Approve buildssystem-design monorepo nextjs pnpmspec
2026-09-19Architectural driverssystem-design offlinespec
2026-09-19Architecture invariantsai-coding architecture invariant agent-guidance api nextjsguidance
2026-09-19Asynchronoussystem-design rag embeddingspec
2026-09-19Atomicsystem-design rag embeddingspec
2026-09-19Authenticationsystem-design security taxila api authentication pwa mobile clispec
2026-09-19Background jobssystem-design vercel background-jobspec
2026-09-19Best practicesai-coding-harness agent-guidance drizzle api react pnpm css offline embedding soft-deleteguidance
2026-09-19Build for today designed for futuresystem-design offlinespec
2026-09-19Bundle everythingsystem-design monorepospec
2026-09-19Capacitorsystem-design native-app nextjs mobile offlinespec
2026-09-19Centralizedsystem-design feature-systemspec
2026-09-19Centralizedsystem-design observability api authenticationspec
2026-09-19Centralized and client agnosticsystem-design security rls api authentication clispec
2026-09-19Centralized copydecision technical architecture-decision authentication typescript clidecision
2026-09-19Centralized copysystem-design contentspec
2026-09-19Checkpoint aplan phase-0 checkpoint nextjs typescript turborepo pnpm cliplan
2026-09-19Checkpoint bplan phase-0 checkpoint eslint prettier turborepo pnpmplan
2026-09-19Checkpoint cplan phase-0 checkpoint nextjs pnpmplan
2026-09-19Checkpoint dplan phase-0 checkpoint api-guard rls supabase drizzle api authenticationplan
2026-09-19CLIsystem-design cli apispec
2026-09-19Commands and environmentai-coding command environment agent-guidance prettier pnpm cliguidance
2026-09-19Context assemblersystem-design ai war-room factory api-guardspec
2026-09-19Coresystem-design feature-systemspec
2026-09-19Corepacksystem-design monorepo pnpmspec
2026-09-19CSS modulessystem-design css eslint mantinespec
2026-09-19DB access chokepointopportunity phase-2 database rls architecture severity-highopportunity
2026-09-19Design constraintssystem-design global-tagging-and-linking rls offline soft-deletespec
2026-09-19dotenv CLIdecision technical architecture-decision clidecision
2026-09-19dotenv CLIsystem-design config clispec
2026-09-19Driving forces and key featuressystem-design api offlinespec
2026-09-19Edge runtimedecision technical architecture-decision supabase api nextjsdecision
2026-09-19Edge runtimesystem-design security supabasespec
2026-09-19Embedding status fieldsystem-design rag embedding typescriptspec
2026-09-19Enforced conventionssystem-designspec
2026-09-19Env vars in turbo JSONdecision technical architecture-decision turborepo verceldecision
2026-09-19Env vars in turbo JSONsystem-design monorepo turborepo vercelspec
2026-09-19Environment contract driftopportunity phase-2 config security developer-experience severity-mediumopportunity
2026-09-19ESLint rules to use tsupdecision technical architecture-decision nextjs eslintdecision
2026-09-19Essential and ideal stateglossary terminologyglossary
2026-09-19Evolvabilitysystem-design rls offlinespec
2026-09-19Factorysystem-design prd feature factoryprd
2026-09-19Feature package workai-coding feature monorepo agent-guidance feature-systemguidance
2026-09-19Flat configdecision technical architecture-decision typescript eslintdecision
2026-09-19Flat configsystem-design code-quality-check eslintspec
2026-09-19Force dynamicdecision technical architecture-decision supabase authentication nextjsdecision
2026-09-19Force dynamicsystem-design nextjs supabasespec
2026-09-19Global tags and metadatasystem-design global-tagging-and-linking taxila zinsser alter-ego war-room factory core-drivespec
2026-09-19GraphQL and Relaysystem-design api graphql relayspec
2026-09-19GraphQL Relaydecision technical architecture-decision api-guard rls supabase drizzle api authentication nextjsdecision
2026-09-19Historic stateglossary terminologyglossary
2026-09-19Idempotent APIsystem-design api idempotencyspec
2026-09-19Independent deploymentssystem-design feature-systemspec
2026-09-19Informationglossary terminologyglossary
2026-09-19Installation at workspace levelsystem-design code-quality-check typescript pnpmspec
2026-09-19Instrumentationsystem-design observability vercel embeddingspec
2026-09-19Isolationsystem-design feature-systemspec
2026-09-19Lifecycle and traceabilityprd governance planningguidance
2026-09-19Mantinesystem-design css mantinespec
2026-09-19Many clients and many consumerssystem-design api pwa ai rag clispec
2026-09-19May become part of Taxilasystem-design core-drive taxilaspec
2026-09-19Middlewaresystem-design security api authenticationspec
2026-09-19Migrationsystem-design database drizzle pnpmspec
2026-09-19Model router contractsystem-design ai model-routing provider-agnostic embeddingspec
2026-09-19Module resolution bundlerdecision technical architecture-decision nextjs typescriptdecision
2026-09-19Module resolution bundlersystem-design code-quality-check typescriptspec
2026-09-19Never mutate outside API layersystem-design apispec
2026-09-19No API versioningdecision technical architecture-decision api-guard api nextjs clidecision
2026-09-19No utility stylesdecision technical architecture-decision eslint mantine cssdecision
2026-09-19Non syncable tablessystem-design database offlinespec
2026-09-19Notesai-coding-harness agent-guidance nextjs eslint pnpm cssguidance
2026-09-19Observabilitysystem-design api-guard api authentication vercel observabilityspec
2026-09-19Observablesystem-design rag embedding observabilityspec
2026-09-19Offline readysystem-design api pwa offline soft-delete idempotencyspec
2026-09-19Package boundary enforcementopportunity phase-2 monorepo eslint architecture severity-mediumopportunity
2026-09-19Parrotsystem-design prd feature parrotprd
2026-09-19Phase 0 foundation tooling completeplan living-plan phase-0 nextjs typescript eslint prettier turborepo pnpmplan
2026-09-19Phase 1 Supabase and auth shellplan phase-1 rls supabase drizzle postgresql authentication nextjsguidance
2026-09-19Phase 1 Supabase auth shell completeplan living-plan phase-1 api-guard rls supabase drizzle postgresql apiplan
2026-09-19Phase 1.1 DB level RLS soft delete enforcement completeplan living-plan phase-1.1 rls supabase drizzle postgresql pnpm soft-deleteplan
2026-09-19Phase 10 observability hardeningplan living-plan phase-10 api-guard rls api authentication nextjs offlineplan
2026-09-19Phase 11 dogfooding friends family access optionalplan living-plan phase-11plan
2026-09-19Phase 12 billing SaaS readiness optional pathplan living-plan phase-12 api offlineplan
2026-09-19Phase 3 graph store metadata serviceplan living-plan phase-3 api-guard rls postgresql api offline graphplan
2026-09-19Phase 4 Taxila v1 knowledge managementplan living-plan phase-4 taxila api-guard rls drizzle api authenticationplan
2026-09-19Phase 5 Zinsser v1 writing editor firstplan living-plan phase-5 taxila zinsser api mantine ai embeddingplan
2026-09-19Phase 6 core drive AI layer alter egoplan living-plan phase-6 taxila zinsser alter-ego war-room factory core-driveplan
2026-09-19Phase 7 war room factory v1plan living-plan phase-7 war-room factory core-drive rls api offlineplan
2026-09-19Phase 8 PWA iOS shellplan living-plan phase-8 taxila alter-ego nextjs vercel pwa mobileplan
2026-09-19Phase 9 API keys CLIplan living-plan phase-9 taxila api-guard rls api authentication cliplan
2026-09-19Post mvp factory extensions bots agentsplan living-plan backlog factory parrot api-guard rls supabase drizzleplan
2026-09-19Prd.features.core drive and Taxilasystem-design prd feature taxila core-driveprd
2026-09-19Prefer JSON configsystem-design monorepospec
2026-09-19Present stateglossary terminologyglossary
2026-09-19Productivity Modulesystem-design prd feature war-roomprd
2026-09-19Profile trigger and referential integrityopportunity phase-2 database authentication integrity severity-highopportunity
2026-09-19Public API onlysystem-design api rls authentication clispec
2026-09-19PWAsystem-design pwa offlinespec
2026-09-19RAGsystem-design rag ai embeddingspec
2026-09-19Repository clientsystem-design database rls supabase drizzle api repository-patternspec
2026-09-19Request flowsystem-design monorepo api offlinespec
2026-09-19Retryablesystem-design rag embeddingspec
2026-09-19Row level securitysystem-design security rls drizzle offlinespec
2026-09-19Runtime database URL driftopportunity phase-2 security rls config severity-highopportunity
2026-09-19Runtime role reproducibilityopportunity phase-2 database security severity-criticalopportunity
2026-09-19Runtime validationsdecision technical architecture-decision api-guard supabase api typescript clidecision
2026-09-19Runtime validationssystem-design typescriptspec
2026-09-19Security proof and ciopportunity phase-2 testing ci security severity-highopportunity
2026-09-19Shared tsconfigdecision technical architecture-decision nextjs clidecision
2026-09-19Shared tsconfigsystem-design code-quality-check typescriptspec
2026-09-19Signup confirmation flowopportunity authentication ux severity-lowopportunity
2026-09-19Soft delete supportsystem-design database soft-deletespec
2026-09-19Start Todayprinciple core-drive execution consistencyguidance
2026-09-19State representationsystem-design api aispec
2026-09-19Structured response attributionsystem-design ai structured-streaming source-attribution citationspec
2026-09-19Syncable tablessystem-design database offline soft-deletespec
2026-09-19System overview diagramsystem-designspec
2026-09-19Task 1.1plan phase-1 task supabase postgresql api authentication nextjsplan
2026-09-19Task 1.10plan phase-1 task rls supabase drizzle postgresql authentication typescriptplan
2026-09-19Task 1.11plan phase-1 task api-guard rls supabase api authentication nextjsplan
2026-09-19Task 1.12and1.13plan phase-1 task supabase api authentication nextjs react typescriptplan
2026-09-19Task 1.14plan phase-1 task react pnpm mantine cssplan
2026-09-19Task 1.15and1.16plan phase-1 task supabase authentication nextjs react mantineplan
2026-09-19Task 1.17plan phase-1 task supabase authenticationplan
2026-09-19Task 1.18plan phase-1 task api-guard rls supabase drizzle postgresql authenticationplan
2026-09-19Task 1.2plan phase-1 task supabase drizzle postgresql authentication nextjs pnpmplan
2026-09-19Task 1.3plan phase-1 task supabase nextjs typescriptplan
2026-09-19Task 1.4plan phase-1 task supabase authentication nextjs react typescriptplan
2026-09-19Task 1.5plan phase-1 task rls supabase authentication typescriptplan
2026-09-19Task 1.6plan phase-1 task rls supabase drizzle authentication typescript offlineplan
2026-09-19Task 1.7plan phase-1 task drizzle postgresql typescriptplan
2026-09-19Task 1.8plan phase-1 task turborepo pnpmplan
2026-09-19Task 1.9plan phase-1 task rls supabase drizzle postgresql authenticationplan
2026-09-19Technology stacksystem-design supabase drizzle postgresql nextjs typescript turborepo pnpm vercelspec
2026-09-19Tiered context modelsystem-design core-drive domain situation war-room factory ai embeddingspec
2026-09-19Tiptap requirementsystem-design rag embeddingspec
2026-09-19tsupsystem-design code-quality-check eslintspec
2026-09-19Turboreposystem-design monorepo turborepo pnpmspec
2026-09-19TypeScript hoistingdecision technical architecture-decision nextjs typescript pnpmdecision
2026-09-19Use corepackdecision technical architecture-decision pnpm verceldecision
2026-09-19Use navigation hooksystem-design nextjsspec
2026-09-19User profile creation by DB triggerdecision technical architecture-decision supabase api authenticationdecision
2026-09-19Verceldecision technical architecture-decision nextjs turborepo pnpm verceldecision
2026-09-19Vercelsystem-design monorepo turborepo pnpm vercelspec
2026-09-19Why Prettier at repo level?decision technical architecture-decision prettier turborepo graphdecision
2026-09-19Why Turborepodecision technical architecture-decision turborepo pnpmdecision
2026-08-24Architectural OverviewSidekick's architectural overview — an API-first platform tuned for solo developerssidekick project technical architecturedocument
2026-07-19Project Sidekick - A Bird’s Eye ViewAn introduction to Project Sidekick. What is it? What problem does it solve? Why am I building it?sidekick project essaydocument
2026-07-10Building in the Margins of RealityProject kick-off post and a confessional essay about my past attempts and their failures. Why start now? What do I hope to achieve?sidekick project essayjournal
2026-05-29Checkpoint A walkthroughbuild walkthrough phase-0 checkpointguidance
2026-05-29Checkpoint C walkthroughbuild walkthrough phase-0 checkpointguidance
2026-05-29Checkpoint D walkthroughbuild walkthrough phase-0 checkpointguidance
2026-05-29Config files explainedbuild walkthrough phase-0 turborepo typescript eslint prettierguidance
2026-05-29Phase 1 walkthroughbuild walkthrough phase-1 supabase drizzle authenticationguidance