Auth
Connection auth - upgrade()
The upgrade function in src/hooks.ws.js runs on every new WebSocket connection. Return user data to attach to the connection, or false to reject it.
// src/hooks.ws.js
export function upgrade({ cookies }) {
const session = validateSession(cookies.session_id);
if (!session) return false;
return { id: session.userId, name: session.name };
} Whatever you return is available as ctx.user in all live() functions.
Per-module auth - guard()
Export a _guard from any src/live/ file. It runs before every function in that file.
// src/live/admin.js
import { live, guard, LiveError } from 'svelte-realtime/server';
export const _guard = guard((ctx) => {
if (ctx.user?.role !== 'admin')
throw new LiveError('FORBIDDEN', 'Admin only');
});
export const deleteUser = live(async (ctx, userId) => {
await db.users.delete(userId);
}); Both functions in this file require admin access.
Composable guards
Chain multiple guards. They run in order, and earlier ones can enrich ctx for later ones:
export const _guard = guard(
(ctx) => {
if (!ctx.user) throw new LiveError('UNAUTHORIZED');
},
(ctx) => {
ctx.permissions = lookupPermissions(ctx.user.id);
},
(ctx) => {
if (!ctx.permissions.includes('write'))
throw new LiveError('FORBIDDEN');
}
); Per-function auth
Check ctx.user inside any live() function:
export const deleteItem = live(async (ctx, id) => {
if (!ctx.user) throw new LiveError('UNAUTHORIZED');
if (ctx.user.role !== 'admin') throw new LiveError('FORBIDDEN');
await db.items.delete(id);
}); live.scoped(predicate, fn) - RPC access wrapper
For RPCs, live.scoped(predicate, fn) is the equivalent of the access option on streams. The predicate runs once per call before the handler; missing ctx.user throws UNAUTHENTICATED, a rejecting predicate throws FORBIDDEN. Composes cleanly with live.validated and live.rateLimit.
import { live } from 'svelte-realtime/server';
export const editOrgSettings = live.scoped(
live.access.org(),
live.validated(SettingsSchema, async (ctx, orgId, patch) => {
return db.orgs.update(orgId, patch);
})
); The same access helpers documented for streams (live.access.owner / team / role / org / user / any / all) work as the predicate. For RPCs gated by something _guard can already express, _guard is the lighter option; live.scoped shines when one handler in a module needs a different gate from the rest of the file.
Stream access control
Use the access option on live.stream() to control who can subscribe. The predicate receives ctx and is checked once at subscription time. The predicate may be sync OR async (return boolean | Promise<boolean>). If it returns false, the subscription is denied with { ok: false, code: 'FORBIDDEN', error: 'Access denied' } and no data is sent. For per-event filtering, use pipe.filter().
Upgrading from 0.4.x: Async predicates that returned
Promise<false>were silently allowing every request before 0.5 because the framework compared the Promise object againstfalsesynchronously. The runtime nowawaits the predicate before the truthiness check. Audit every asyncaccess/filterpredicate, every asynclive.gate(...), and every asyncsubscribe/subscribeBatchhook - predicates that were silently letting requests through will now correctly deny. See Migration 0.4 to 0.5.
// Only admins can subscribe
export const adminFeed = live.stream('admin-feed', async (ctx) => {
return db.adminEvents.recent();
}, {
merge: 'crud',
access: (ctx) => ctx.user?.role === 'admin'
});
// Role-based: different roles get different access
export const items = live.stream('items', async (ctx) => {
return db.items.all();
}, {
merge: 'crud',
access: live.access.role({
admin: true,
viewer: false
})
}); For per-user data isolation, use dynamic topics so each user subscribes to their own topic:
// Each user gets their own topic - no cross-user data leakage
export const myOrders = live.stream(
(ctx) => `orders:${ctx.user.id}`,
async (ctx) => db.orders.forUser(ctx.user.id),
{ merge: 'crud', key: 'id' }
); | Helper | Description |
|---|---|
live.access.owner(field?) | Subscription allowed if ctx.user[field] is present (default: 'id') |
live.access.team() | Subscription allowed if ctx.user.teamId is present |
live.access.role(map) | Role-based: { admin: true, viewer: (ctx) => ... } |
live.access.any(...predicates) | OR: any predicate returning true allows the subscription |
live.access.all(...predicates) | AND: all predicates must return true |
import { live } from 'svelte-realtime/server';
export const orgFeed = live.stream(
(ctx) => `org:${ctx.user.orgId}:feed`,
loader,
{
access: live.access.all(
live.access.owner('id'),
live.access.role({ admin: true, member: true })
)
}
); Async predicates inside
any/all. The composition helpersawaiteach sub-predicate in order.anyreturnstrueon the first truthy sub-predicate;allreturnsfalseon the first falsy sub-predicate. Sync and async sub-predicates compose freely. Note: callers that invoke the returned predicate manually (rare; the typical pattern is to pass it tolive.stream({ access })where the runtime awaits it) mustawaitthe result. The leaf helpers (live.access.owner,live.access.team,live.access.role,live.access.org,live.access.user) remain sync.
Global middleware
Use live.middleware() for cross-cutting logic that runs before per-module guards on every RPC and stream call. Common shapes are authentication, logging, and per-request context enrichment:
import { live, LiveError } from 'svelte-realtime/server';
// Logging middleware
live.middleware(async (ctx, next) => {
const start = Date.now();
const result = await next();
console.log(`[${ctx.user?.id}] took ${Date.now() - start}ms`);
return result;
});
// Auth middleware - rejects unauthenticated requests globally
live.middleware(async (ctx, next) => {
if (!ctx.user) throw new LiveError('UNAUTHORIZED', 'Login required');
return next();
}); Multiple live.middleware(...) calls compose: middleware runs in registration order, and each must call next() to continue the chain. The chain terminates with the per-module _guard (if any) and then the handler. A middleware that throws short-circuits the chain - downstream middleware and the handler do not run, and the thrown error propagates back to the client. When no middleware is registered, there is zero overhead.
Publicly callable RPCs
The default framework posture is “any authenticated WS can invoke any registered handler unless a _guard says otherwise”. This matches every other web framework and keeps the zero-config “Hello, world” path intact. For a non-trivial module without a guard, the vite codegen warns at build / dev time:
[svelte-realtime] src/live/feed.js exports live() handlers but no _guard.
Add `export const _guard = guard(...)`, wrap with live.public(fn),
or add a `// realtime-allow-public` comment to silence this warning. Three opt-out paths, each with its own intent:
// Option 1: per-handler intent marker
import { live } from 'svelte-realtime/server';
export const publicFeed = live.public(async (ctx) => db.feed.recent()); live.public(fn) is a runtime no-op - it returns the handler unchanged. Its only job is intent: “this RPC is intentionally public, do not warn.” Wrap individual handlers to mark them safe to expose.
// Option 2: module-wide comment
// realtime-allow-public
import { live } from 'svelte-realtime/server';
export const ping = live(async () => 'pong');
export const stats = live(async () => db.stats.summary()); The comment can be a line comment (// realtime-allow-public) or a block comment (/* realtime-allow-public */). It applies to the whole module - lighter than wrapping every handler when the entire file is public by design.
// Option 3: write a guard
import { live, guard, LiveError } from 'svelte-realtime/server';
export const _guard = guard((ctx) => {
if (!ctx.user) throw new LiveError('UNAUTHENTICATED');
});
export const myFeed = live(async (ctx) => db.feed.forUser(ctx.user.id)); This is the strongest signal: the module is gated, and the warning goes away because the guard is the authorization.
Runtime behavior is unchanged across all three - the warning is a build-time nudge, not a hard error. Apps that forget a guard see the warning; apps that intentionally want a public module pick one of the three opt-outs and the warning is silent.
Server-initiated push
Forward:
live.push/live.notifydeliver to whicheveruserIdthe caller passes. See Trust model:target.userIdis whatever the caller passes below before wiring user-controlled values through.
live.push(target, event, data) and live.notify(target, event, data) send a server-initiated frame to one or more connections. The target is identity-shaped:
import { live } from 'svelte-realtime/server';
export const sendNotice = live.notify(async (ctx, recipientId, text) => {
return {
target: { userId: recipientId },
event: 'notice',
data: { from: ctx.user.id, text }
};
}); The framework looks up every connection registered under target.userId (across the cluster when live.configurePush({ remoteRegistry }) is wired) and delivers the frame. live.push returns when at least one delivery succeeds; live.notify is fire-and-forget.
Trust model: target.userId is whatever the caller passes
The framework is a server-trust primitive: whoever calls live.push is asserting that the target.userId is the correct delivery destination. The framework does not check it.
The failure mode is shaped like this:
// WRONG: the recipientId is wire-controlled
export const sendDirectMessage = live.notify(async (ctx, msg) => {
return {
target: { userId: msg.to }, // msg.to is whatever the client said
event: 'dm',
data: { from: ctx.user.id, text: msg.text }
};
}); A client can pass any msg.to and the framework delivers the frame to that user. The bug ships unnoticed because the code “looks right” - the handler accepts a parameter and uses it.
The fix is an ownership check before the value is routed:
import { live, LiveError } from 'svelte-realtime/server';
function mustOwnUser(ctx, targetUserId) {
if (!ctx.user) throw new LiveError('UNAUTHENTICATED');
if (ctx.user.role === 'admin') return; // admin override
if (ctx.user.id === targetUserId) return; // self-targeted
if (ctx.user.tenantId && targetUserId.startsWith(`${ctx.user.tenantId}:`)) return; // tenant peer
throw new LiveError('FORBIDDEN');
}
export const sendDirectMessage = live.notify(async (ctx, msg) => {
mustOwnUser(ctx, msg.to);
return {
target: { userId: msg.to },
event: 'dm',
data: { from: ctx.user.id, text: msg.text }
};
}); The helper is a named, single-purpose gate. The four cases above are illustrative; an app’s actual ownership logic may include team membership, room membership, or anything else ctx.user knows about. The point is the structure: the handler must call the gate before the userId reaches the framework.
A userId is safe to pass when its source is server-trust:
- Server-authored database rows (
db.orders.findOne(...).customerId). ctx.user.id(set byupgrade()).- Verified webhook claims with HMAC- or signature-validated payload.
- A cron handler’s own scheduled state.
It is unsafe without an ownership check when its source is the wire:
- Any field from the RPC’s
argsarray. - A query-string value forwarded into the handler.
- Anything destructured from a JSON envelope without prior validation.
The same trust contract applies to any future push-target shape - { group: ... }, { role: ... }, { tenant: ... }. The gate is the application’s job; the framework will route to whatever it is given.
See Authorization model for the canonical statement of this contract across every primitive.
Under the hood, upgrade() runs inside the adapter’s WebSocket handshake. See svelte-adapter-uws WebSocket docs for the full connection lifecycle.
Was this page helpful?