Skip to content

Route Policy

Route policy is where your entity goes from “open CRUD” to “production API.” Every generated route — CRUD and operations — can be individually configured.

The most common pattern is “private by default, public reads”:

const Post = defineEntity('Post', {
namespace: 'blog',
fields: {
id: field.string({ primary: true, default: 'uuid' }),
authorId: field.string(),
title: field.string(),
body: field.string(),
status: field.enum(['draft', 'published'] as const, { default: 'draft' }),
createdAt: field.date({ default: 'now' }),
},
routes: {
defaults: { auth: 'userAuth' },
list: { auth: 'none' },
get: { auth: 'none' },
},
});

defaults sets the base policy for all routes. Per-route entries override it. Here, list and get are public while create, update, and delete require a valid session.

ModeWhat it checks
'userAuth'Requires a valid user session (JWT or cookie)
'bearer'Requires a valid bearer token (API key or M2M)
'none'No authentication required

Require a permission string. The permissions plugin checks if the actor has been granted it:

routes: {
defaults: { auth: 'userAuth' },
create: {
permission: { requires: 'blog:post.write' },
},
},

Only the record owner can perform the action:

routes: {
update: {
permission: {
requires: 'blog:post.write',
ownerField: 'authorId',
},
},
},

The framework checks: “Does this user have blog:post.write, AND is their user ID the value in the record’s authorId field?” Both must pass.

Let the owner edit their own post, OR let a moderator edit anyone’s:

routes: {
update: {
permission: {
requires: 'blog:post.write',
ownerField: 'authorId',
or: 'blog:post.moderate',
},
},
delete: {
permission: {
requires: 'blog:post.delete',
ownerField: 'authorId',
or: 'blog:post.moderate',
},
},
},

Logic: (has 'blog:post.write' AND is owner) OR (has 'blog:post.moderate').

Pass dynamic values from the request into the permission check:

routes: {
update: {
permission: {
requires: 'project:task.write',
scope: { projectId: 'param:projectId' },
},
},
},

This checks: “Does this user have project:task.write scoped to this specific project ID?”

Throttle specific routes to prevent abuse:

routes: {
defaults: {
rateLimit: { windowMs: 60_000, max: 60 }, // 60 requests per minute
},
create: {
rateLimit: { windowMs: 60_000, max: 10 }, // tighter limit for writes
},
list: {
rateLimit: { windowMs: 60_000, max: 120 }, // more generous for reads
},
},

Publish an event when a route completes successfully. This is how entity routes participate in the app’s event system:

routes: {
create: {
event: {
key: 'blog:post.created',
payload: ['id', 'authorId', 'title'],
},
},
update: {
event: {
key: 'blog:post.updated',
payload: ['id', 'title'],
},
},
delete: {
event: {
key: 'blog:post.deleted',
payload: ['id'],
},
},
},

Listeners anywhere in the app can subscribe:

bus.on('blog:post.created', async ({ payload }) => {
await indexForSearch(payload.id);
await notifyFollowers(payload.authorId, payload.title);
});

Make events available to browser clients via SSE:

routes: {
create: {
event: {
key: 'blog:post.created',
payload: ['id', 'title'],
exposure: ['client-safe'],
},
},
},

Control who receives the event in multi-tenant or per-user scenarios:

routes: {
create: {
event: {
key: 'chat:message.sent',
payload: ['id', 'channelId', 'body'],
scope: { tenantId: 'record:orgId', resourceId: 'record:channelId' },
},
},
},

Protect write endpoints from duplicate submissions:

routes: {
create: {
idempotency: {
scope: 'user', // one idempotency key per user
ttl: 86400, // key expires after 24 hours
},
},
},

Clients send an Idempotency-Key header. If the same key is sent twice within the TTL, the second request returns the cached response without executing again.

ScopeDeduplication boundary
'user'Per authenticated user
'tenant'Per tenant
'global'Across all users and tenants

Shorthand: idempotency: true uses the defaults (scope: 'user', ttl: 86400).

Apply named middleware to specific routes:

const Post = defineEntity('Post', {
namespace: 'blog',
fields: {
/* ... */
},
routes: {
middleware: {
logSlowQueries: async (c, next) => {
const start = Date.now();
await next();
const ms = Date.now() - start;
if (ms > 500) console.warn(`Slow query: ${c.req.path} took ${ms}ms`);
},
},
defaults: { auth: 'userAuth' },
list: {
auth: 'none',
middleware: ['logSlowQueries'],
},
},
});

Named operations get the same policy options as CRUD routes. Configure them under routes.operations:

routes: {
defaults: { auth: 'userAuth' },
operations: {
publish: {
method: 'post',
path: '/:id/publish',
permission: { requires: 'blog:post.publish' },
event: { key: 'blog:post.published', payload: ['id', 'title'] },
},
byAuthor: {
auth: 'none', // public lookups
},
bySlug: {
auth: 'none',
},
},
},

You can also override the HTTP method and path for any operation.

Turn off specific generated routes entirely:

routes: {
disable: ['delete'], // no DELETE endpoint
},
src/entities/task.ts
import { defineEntity, field, index } from '@lastshotlabs/slingshot';
export const Task = defineEntity('Task', {
namespace: 'app',
fields: {
id: field.string({ primary: true, default: 'uuid' }),
projectId: field.string(),
assigneeId: field.string({ optional: true }),
title: field.string(),
description: field.string({ optional: true }),
status: field.enum(['todo', 'in_progress', 'done'] as const, { default: 'todo' }),
priority: field.integer({ default: 0 }),
createdAt: field.date({ default: 'now' }),
},
indexes: [index(['projectId', 'status'])],
tenant: { field: 'projectId' },
routes: {
defaults: {
auth: 'userAuth',
rateLimit: { windowMs: 60_000, max: 60 },
},
list: {
rateLimit: { windowMs: 60_000, max: 120 },
},
create: {
permission: { requires: 'task:write', scope: { projectId: 'param:projectId' } },
event: { key: 'task:task.created', payload: ['id', 'projectId', 'title'] },
idempotency: { scope: 'user' },
},
update: {
permission: {
requires: 'task:write',
ownerField: 'assigneeId',
or: 'task:manage',
},
event: { key: 'task:task.updated', payload: ['id', 'title', 'status'] },
},
delete: {
permission: { requires: 'task:manage' },
},
operations: {
start: {
permission: { requires: 'task:write' },
event: { key: 'task:task.started', payload: ['id'] },
},
complete: {
permission: { requires: 'task:write' },
event: { key: 'task:task.completed', payload: ['id'], exposure: ['client-safe'] },
},
},
},
});

Every route is locked down. Events flow on write operations. Rate limits protect against abuse. Idempotency prevents duplicate task creation. And it is all in one place.