Protect routes with authz
Your workspace service still answers anyone — and after chapters 3 and 4 it fronts data with real
consequences: the member list of a specific team. The route that returns it has to do two things at
once, and both must be typed. It has to know which team is being asked for — a workspace id in
the path, not a string you fish out of the URL by hand — and it has to fail closed: no credential
means 401 before the handler runs, and a valid credential without the right scope means 403.
This chapter builds exactly that pair. You declare the members route once as a bound route
contract — createRouteReference / defineRouteContract + bindRoutePattern from
@netscript/fresh/route — so the URL pattern, the typed { workspace } path param, and the
pagination search all come from a single object your service and any client share. Then you layer the
.withAuthn() / .withAuthz() guard from @netscript/service/auth on top of it — the seam
NetScript ships and proves in its own builder-auth test suite. The result is the combination this
chapter's title promises: a typed route and its authorization gate, from one source of truth.
What you will build
A guarded GET /api/workspace/:workspace/members route. One bound route contract declares its
pattern, its typed { workspace } path param, and its typed limit/offset pagination. The service
registers that route, reads its params through the contract (no hand-parsing), and gates it with
.withAuthn() (which turns each request into a Principal) and .withAuthz() (which decides whether
that principal may reach the route). By the end an unauthenticated request returns 401 UNAUTHORIZED,
a request with the wrong scope returns 403 FORBIDDEN, and a correctly-scoped request returns 200
with a typed member list — the exact three outcomes the framework's own test asserts.
Prerequisites
- The
workspaceservice and its typed contracts from chapter 1. - The
Membermodel and theworkspaceDbclient from chapter 3 — the route lists members from that datasource. - The route-authz seam is built into
@netscript/service, and the route contract into@netscript/fresh/route; neither needs Aspire or the auth plugin to type-check, though you run the service under Aspire to exercise it live.
Confirm the workspace still builds before you change it:
# In my-workspace/
deno task check
Step 1 — Declare the bound route contract
Before the guard, give the route a typed identity. A route contract from @netscript/fresh/route is
the single source of truth for a route's shape: its pattern, its path params, and its search params.
createRouteReference infers the { workspace } param straight from the pattern; defineRouteContract
bindRoutePatternadd typed pagination search with safe defaults. Create the contract in the sharedcontracts/tree so the service and any future client import the same object:
// contracts/routes/workspace-members.ts
import {
bindRoutePattern,
createRouteReference,
defineRouteContract,
fallback,
paginationSearchSchema,
} from '@netscript/fresh/route';
import { z } from 'zod';
/** The one place the members route pattern is written. */
export const MEMBERS_ROUTE_PATTERN = '/api/workspace/[workspace]/members';
/**
* The bound members route: typed `{ workspace }` path param + typed
* `limit`/`offset` pagination, all inferred from this one declaration.
*/
export const membersRoute = bindRoutePattern(
defineRouteContract({
// A path schema types the dynamic `[workspace]` segment as a non-empty id.
pathSchema: z.object({ workspace: z.string().min(1) }),
// paginationSearchSchema() gives limit/offset with computed defaults.
searchSchema: paginationSearchSchema({ defaultLimit: 20 }).extend({
// A junk ?role=banana falls back to undefined instead of throwing.
role: fallback(z.enum(['member', 'admin']).optional(), undefined),
}),
}),
MEMBERS_ROUTE_PATTERN,
);
bindRoutePattern returns one object with everything downstream needs: membersRoute.parsePath(...)
and membersRoute.parseSearch(...) turn raw request params into typed values, and
membersRoute.href({ path: { workspace } }) builds the URL for a client — so a link and the handler
that answers it can never disagree about the route's shape.
| Name | Type | Description |
|---|---|---|
createRouteReference(pattern) |
RouteReference |
Infers typed path params directly from a Fresh route pattern — /api/workspace/[workspace]/members yields { workspace: string }. |
defineRouteContract({ pathSchema?, searchSchema? }) |
DefineRouteContract |
Declares typed path and search schemas; bind it to one or more concrete patterns. |
bindRoutePattern(contract, pattern) |
BoundRouteContract |
Binds the contract to a pattern, returning one object with .parsePath / .parseSearch / .href. |
paginationSearchSchema(opts) / fallback(schema, default) |
search schema |
Typed limit/offset with computed defaults; fallback() catches junk query strings instead of 500-ing the route. |
Step 2 — Build the authenticator and scope authorizer
Now the guard. Define the credentials and the scope rule. This mirrors the framework's own
builder-auth_test.ts: a read credential carries the workspace:read scope, and the authorizer
requires that scope on the members route. The rule matches the same prefix the contract declares,
so what the contract types and what the guard protects stay in lockstep:
// services/workspace/src/auth.ts
import {
createScopeAuthorizer,
createStaticCredentialAuthenticator,
} from '@netscript/service/auth';
export const authenticator = createStaticCredentialAuthenticator({
credentials: {
read: {
subject: 'user:reader',
scopes: ['workspace:read'],
roles: ['reader'],
},
write: {
subject: 'user:writer',
scopes: ['workspace:write'],
roles: ['writer'],
},
},
});
export const authorizer = createScopeAuthorizer({
rules: [{
// Guards every /api/workspace/<id>/members request the contract addresses.
match: (request) => request.path.startsWith('/api/workspace/'),
requireScopes: ['workspace:read'],
}],
});
| Name | Type | Description |
|---|---|---|
createStaticCredentialAuthenticator(opts) |
AuthenticatorPort |
Maps bearer tokens to principals — each credential carries a subject, scopes, and roles. Good for tests and machine-to-machine callers. |
createScopeAuthorizer(opts) |
AuthorizerPort |
Rules of { match, requireScopes } — the principal must carry every required scope for a matched route. |
.withAuthn({ authenticator, protect?, allowAnonymous? }) |
builder stage |
protect defaults to ['/api']; allowAnonymous defaults to ['/health']. |
.withAuthz({ authorizer, denyByDefault? }) |
builder stage |
denyByDefault defaults to true — fail closed when no decision is reachable. |
Step 3 — Register the typed route and layer the guard
Register the members route on the service builder, then apply .withAuthz() and .withAuthn(). The
handler runs only for an authenticated, authorized caller — and it reads its params through the
contract, so workspace, limit, and offset arrive typed, never hand-sliced from the URL:
// services/workspace/src/main.ts
import { createService } from '@netscript/service';
import type { Principal } from '@netscript/service/auth';
import { membersRoute } from '../../../contracts/routes/workspace-members.ts';
import { workspaceDb } from './db.ts'; // the chapter 3 workspace client
import { authenticator, authorizer } from './auth.ts';
type RouteCtx = {
get(key: string): unknown;
json(data: unknown): Response;
req: { param(): Record<string, string>; url: string };
};
const app = createService({}, { name: 'workspace' })
.route('get', '/api/workspace/:workspace/members', async (c: unknown) => {
const ctx = c as RouteCtx;
// .withAuthn injected the resolved principal; the handler only sees allowed callers.
const principal = ctx.get('principal') as Principal;
// Typed off the ONE route contract — no manual URL parsing.
const { workspace } = membersRoute.parsePath(ctx.req.param());
const { limit, offset } = membersRoute.parseSearch(new URL(ctx.req.url).searchParams);
const members = await workspaceDb.member.findMany({
where: { workspaceId: workspace },
take: limit,
skip: offset,
orderBy: { createdAt: 'asc' },
});
return ctx.json({ workspace, limit, offset, subject: principal.subject, members });
})
.withAuthz({ authorizer })
.withAuthn({ authenticator })
.build();
export { app };
The route pattern the service registers (:workspace) and the contract's pattern ([workspace]) name
the same segment; membersRoute.parsePath(ctx.req.param()) is what turns the router's raw params into
the typed { workspace } the handler uses.
Test it out
The guard produces three distinct responses, each a real assertion in the framework's
builder-auth_test.ts. Drive them against the running service — start it under Aspire
(aspire start from aspire/), then call the route for team ws-1 (note: this tutorial assumes port 3001; in unpinned scaffolds, each project is allocated its own randomized high-range ports):
# No Authorization header -> authn rejects before the handler runs.
curl -i http://localhost:3001/api/workspace/ws-1/members
# HTTP/1.1 401 Unauthorized
# { "error": "UNAUTHORIZED", "message": "missing-credential" }
# 'write' authenticates (valid credential) but lacks workspace:read,
# so authz denies the scope-guarded route.
curl -i -H 'authorization: Bearer write' \
http://localhost:3001/api/workspace/ws-1/members
# HTTP/1.1 403 Forbidden
# { "error": "FORBIDDEN", "message": "authz.missing-scope:workspace:read" }
# 'read' carries workspace:read -> authn resolves the principal, authz allows it.
# The contract parses ?limit=2 into a typed page.
curl -i -H 'authorization: Bearer read' \
'http://localhost:3001/api/workspace/ws-1/members?limit=2'
The 200 body echoes the values the contract parsed — the typed workspace path param, the typed
limit/offset page, the authenticated subject, and the member rows from chapter 3:
{
"workspace": "ws-1",
"limit": 2,
"offset": 0,
"subject": "user:reader",
"members": [
{ "id": "mem_01", "workspaceId": "ws-1", "subject": "user:alice", "role": "member" }
]
}
An empty members array is still a 200 — it means the guard passed and the team simply has no rows
yet (provision one with the chapter 4 job). A 401 or 403
means the request never reached the query at all.
Verify your progress
The three curl calls above are the verification. The unauthenticated call must fail closed, and the
scoped call must succeed with a typed body:
- [ ]
contracts/routes/workspace-members.tsexportsmembersRoute, a bound route contract with a typed{ workspace }param and pagination search. - [ ]
services/workspace/src/auth.tsdefines the authenticator and a scope authorizer. - [ ] The
workspaceservice registersGET /api/workspace/:workspace/membersand applies.withAuthn()and.withAuthz(). - [ ] The handler reads params via
membersRoute.parsePath(...)/.parseSearch(...), not by hand. - [ ] An unauthenticated request returns
401 UNAUTHORIZED(missing-credential). - [ ]
Bearer writereturns403 FORBIDDEN(authz.missing-scope:workspace:read). - [ ]
Bearer readreturns200with a body carryingworkspace,limit, andsubject. - [ ]
GET /healthstill answers without a credential.
What you built
A guarded, typed members route: one bound route contract in contracts/routes/workspace-members.ts
owns the pattern, the { workspace } path param, and the pagination search; the workspace service
registers that route, parses its params through the contract, and gates it with .withAuthn() and
.withAuthz() — proven by a 401 for an anonymous request, a 403 for the wrong scope, and a 200
for a correctly-scoped one. That is the differentiator this chapter exists to show: the URL, its typed
params, and its authorization gate are not three hand-maintained facts that can drift — they are
one contract plus one guard, checked by the compiler and by the framework's own builder-auth test.
You also saw the boundary: this is route-level scope authz, not org/role RBAC — the tenancy stays
yours.
Next Steps
- Ship it. Chapter 6 · Deploy runs the whole authenticated workspace locally under Aspire and takes it to production.
- Reuse the contract on the client.
membersRoute.href({ path: { workspace } })builds the same URL a frontend link would call — see the typed route contract in action on a page in the live-dashboard track. - Go deeper on identity. The auth plugin guide covers resolving
real human sessions into the
Principalthis guard authorizes.