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.
Start with defaults
Section titled “Start with defaults”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.
Auth modes
Section titled “Auth modes”| Mode | What 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 |
Permissions
Section titled “Permissions”Simple role-based
Section titled “Simple role-based”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' }, },},Owner-based
Section titled “Owner-based”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.
Owner OR moderator
Section titled “Owner OR moderator”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').
Scoped permissions
Section titled “Scoped permissions”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?”
Rate limits
Section titled “Rate limits”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 },},Events
Section titled “Events”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);});Client-safe events for realtime
Section titled “Client-safe events for realtime”Make events available to browser clients via SSE:
routes: { create: { event: { key: 'blog:post.created', payload: ['id', 'title'], exposure: ['client-safe'], }, },},Event scoping
Section titled “Event scoping”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' }, }, },},Idempotency
Section titled “Idempotency”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.
| Scope | Deduplication boundary |
|---|---|
'user' | Per authenticated user |
'tenant' | Per tenant |
'global' | Across all users and tenants |
Shorthand: idempotency: true uses the defaults (scope: 'user', ttl: 86400).
Middleware
Section titled “Middleware”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'], }, },});Operation route policy
Section titled “Operation route policy”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.
Disable routes
Section titled “Disable routes”Turn off specific generated routes entirely:
routes: { disable: ['delete'], // no DELETE endpoint},Complete example: a SaaS task board
Section titled “Complete example: a SaaS task board”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.
- Events and the Event Bus — subscribe to entity events
- Generated Routes, Overrides, and Extra Routes — customize generated routes