Security
v0.29.34The 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.
@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.public | Reads | Mutations |
|---|---|---|
unset / false | require auth | require auth |
'read' | public | require auth |
true | public | public |
// 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.
// 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.
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.
// 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.
// 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.
@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.
// 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.
// 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.
@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.publicis what you intend. Default is private; only settrue/'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 useapi.writablefor a strict allowlist, so writes can't tamper with them. - Wire your auth layer to populate
locals.user/locals.sessionwith a real object (or setlocals.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.