Security

v0.29.34

The routes SMRT generates ship with safe defaults baked in: private unless you opt out, secret fields that never leave the server, and write bodies that can't be used for mass-assignment. This page is the security model of the generated surface — what's protected automatically, and what you still own.

Fail-closed by default

If you do not set api.public, the generated posture is false: every route requires an authenticated principal. A request with no principal gets 401 Authentication required — for reads as well as writes.

src/lib/models/invoice.ts
typescript
@smrt({
  api: { include: ['list', 'get', 'create', 'update', 'delete'] }
})
export class Invoice extends SmrtObject {
  collection = 'invoices';
  amount: number = 0.0;
}

// No api.public is set, so PUBLIC_ACCESS === false.
// GET /invoices with no authenticated principal → 401.

Opting in to public access

You make a route public deliberately, per object, with api.public:

api.publicReadsMutations
unset / falserequire authrequire auth
'read'publicrequire auth
truepublicpublic
typescript
// All routes public (reads AND writes):
@smrt({ api: { public: true } })

// Reads public, mutations still require auth:
@smrt({ api: { public: 'read' } })

Note that 'read' opens reads but keeps every mutating verb (POST/PUT/DELETE and any non-GET custom action) behind auth.

What counts as authenticated

The generated guard, requireRouteAuth, treats a request as authenticated only when locals carries a resolved, object-shaped principal — locals.user or locals.session as objects, or the explicit locals.smrtAuth === true marker. This is deliberately strict.

typescript
// Generated into every route file.
const PUBLIC_ACCESS: boolean | 'read' = false;

function hasAuthenticatedPrincipal(locals: unknown): boolean {
  if (!locals || typeof locals !== 'object') return false;
  const l = locals as Record<string, unknown>;
  // Only a resolved, object-shaped principal counts. A callable auth() helper
  // (Auth.js puts one on every request, anonymous included) is intentionally
  // NOT treated as a signal — honoring it would fail OPEN.
  const isResolvedPrincipal = (v: unknown) =>
    typeof v === 'object' && v !== null;
  return (
    isResolvedPrincipal(l.user) ||
    isResolvedPrincipal(l.session) ||
    l.smrtAuth === true
  );
}

function requireRouteAuth(locals: unknown, mutating: boolean): void {
  if (PUBLIC_ACCESS === true) return;
  if (PUBLIC_ACCESS === 'read' && !mutating) return;
  if (!hasAuthenticatedPrincipal(locals)) {
    throw error(401, 'Authentication required');
  }
}

Sensitive fields never leave the server

Mark a field @field({ sensitive: true }) and it is excluded from the public serialization used by every generated route.

src/lib/models/api-key.ts
typescript
import { SmrtObject, smrt, field } from '@happyvertical/smrt-core';

@smrt({ api: { public: 'read' } })
export class ApiKey extends SmrtObject {
  collection = 'api_keys';

  label: string = '';

  @field({ sensitive: true })
  secret: string = '';   // never serialized over the wire
}

Excluded from responses

toJSON() still carries sensitive fields for database persistence, but generated routes serialize exclusively with toPublicJSON(), which drops every sensitive field (including STI @meta fields nested under _meta_data). Custom action results are recursively routed through the same serializer, so a sensitive field can't leak through a method's return value either.

typescript
// toJSON()        → { id, label, secret }   (used for DB persistence)
// toPublicJSON()  → { id, label }           (used by every generated route)

// Generated routes ALWAYS serialize with toPublicJSON():
return json(item.toPublicJSON());

Rejected as WHERE filters

Hiding a field from responses isn't enough on its own: an attacker could otherwise probe its value by filtering — where: { secret: 'guess-1' }, then 'guess-2', watching which query returns a row. SMRT closes that oracle. Filtering on a sensitive column throws, and the check runs before operator and field validation so the probe is blocked even for otherwise-valid operators.

typescript
// Allowed:
await apiKeys.list({ where: { label: 'production' } });

// Rejected — throws before the query runs:
await apiKeys.list({ where: { secret: 'guess-1' } });
// Error: Invalid WHERE clause field: 'secret'.
//        Filtering on sensitive fields is not allowed.

Read-only fields can't be written from the client

Mark a field @field({ readonly: true }) for values the server controls — status, ownership, computed flags — and the generated create/update handlers strip it from the request body before it reaches the model.

src/lib/models/account.ts
typescript
@smrt({ api: { include: ['create', 'update'] } })
export class Account extends SmrtObject {
  collection = 'accounts';

  email: string = '';

  @field({ readonly: true })
  status: string = 'pending';   // server-controlled, not client-writable
}

The write-stripping policy

Every generated create and update handler runs the request body through applyWritablePolicy() first. It removes _-prefixed keys, server-managed fields (id, tenantId, the timestamp columns), and every @field({ readonly }) field.

typescript
// Generated into every create/update route.
const WRITABLE_ALLOWLIST: string[] | null = null;
const READONLY_FIELDS: string[] = ['status'];
const SERVER_MANAGED_FIELDS = [
  'id', 'tenantId', 'tenant_id',
  'createdAt', 'created_at', 'updatedAt', 'updated_at',
];

function applyWritablePolicy(data: unknown): Record<string, unknown> {
  if (!data || typeof data !== 'object') return {};
  const result: Record<string, unknown> = {};
  for (const [key, value] of Object.entries(data)) {
    if (key.startsWith('_')) continue;            // strip _-prefixed
    if (SERVER_MANAGED_FIELDS.includes(key)) continue;
    if (READONLY_FIELDS.includes(key)) continue;  // strip @field readonly
    if (WRITABLE_ALLOWLIST && !WRITABLE_ALLOWLIST.includes(key)) continue;
    result[key] = value;
  }
  return result;
}

// In the POST handler:
const data = applyWritablePolicy(await request.json());
const item = await collection.create(data);

This blocks mass-assignment

Without this guard, a client could set fields you never intended to expose by stuffing them into the JSON body — the classic mass-assignment vulnerability. Because stripping happens server-side before create()/update(), extra keys are simply dropped.

text
// Client sends:
POST /accounts
{ "email": "a@b.com", "status": "active", "id": "spoofed" }

// applyWritablePolicy strips status (readonly) and id (server-managed):
// → { email: "a@b.com" }
// The account is created as 'pending', with a server-generated id.

Tightening further with an allowlist

Read-only stripping is a denylist. For a strict allowlist — only an explicit set of fields may ever be written from a request body — set api.writable. Anything not on the list is dropped, regardless of whether it is marked read-only.

src/lib/models/profile.ts
typescript
@smrt({
  api: {
    include: ['create', 'update'],
    // Only these fields may be set from a request body.
    writable: ['email', 'displayName']
  }
})
export class Profile extends SmrtObject {
  collection = 'profiles';
  email: string = '';
  displayName: string = '';
  role: string = 'member';   // not in writable → can never be set via API
}

Shippable checklist

Before exposing a generated endpoint:

  • Confirm the object's api.public is what you intend. Default is private; only set true / 'read' where you mean it.
  • Mark every secret column (credentials, tokens, tax IDs) @field({ sensitive: true }). It is then absent from responses and unfilterable.
  • Mark server-controlled columns @field({ readonly: true }), or use api.writable for a strict allowlist, so writes can't tamper with them.
  • Wire your auth layer to populate locals.user / locals.session with a real object (or set locals.smrtAuth = true). The guard does not infer authentication from a callable helper.
  • Remember mutations stay protected under public: 'read' — verify that matches the access you want.

You still own your auth layer

For finer-grained, per-row authorization and tenant isolation, see the tenancy and users packages, which integrate with this guard (tenant-scoped objects additionally establish a tenant context inside each generated handler).

Grounded in packages/core/src/object.ts, packages/core/src/collection.ts, and packages/core/src/vite-plugin/sveltekit-generator.ts in the SMRT framework source.