# 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.**

1. [1 · Scaffold](https://rickylabs.github.io/netscript/netscript/tutorials/workspace/01-scaffold/)
2. [2 · Auth](https://rickylabs.github.io/netscript/netscript/tutorials/workspace/02-auth/)
3. [3 · Workspace data](https://rickylabs.github.io/netscript/netscript/tutorials/workspace/03-workspace-data/)
4. [4 · Provision job](https://rickylabs.github.io/netscript/netscript/tutorials/workspace/04-provision-job/)
5. [5 · Route authz](https://rickylabs.github.io/netscript/netscript/tutorials/workspace/05-route-authz/)
6. [6 · Deploy](https://rickylabs.github.io/netscript/netscript/tutorials/workspace/06-deploy/)

## 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 `workspace` service and its typed contracts from [chapter 1](https://rickylabs.github.io/netscript/tutorials/workspace/01-scaffold/).
- The `Member` model and the `workspaceDb` client from [chapter 3](https://rickylabs.github.io/netscript/tutorials/workspace/03-workspace-data/) — 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:

```sh
# In my-workspace/
deno task check
```

> Two layers, one Principal
>
> The
>
> auth plugin
>
> (chapter 2) signs
>
> human users
>
> in and resolves their sessions. The seam in
>
> this
>
> chapter gates a
>
> service's own routes
>
> — it is provider-agnostic and built into
>
> @netscript/service
>
> . Both layers speak the same
>
> Principal
>
> type, so they compose: the plugin establishes identity, and a service's
>
> .withAuthn()
>
> turns a request into that
>
> Principal
>
> for an authorization decision.

## 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`

- `bindRoutePattern` add typed pagination search with safe defaults. Create the contract in the shared `contracts/` tree so the service and any future client import the *same* object:

```ts
// 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.

**@netscript/fresh/route — the bound route contract surface**

| 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. |

> One param name, two path syntaxes
>
> Route contracts use Fresh's
>
> [workspace]
>
> pattern syntax; a service (Hono) route registers the same segment as
>
> :workspace
>
> . It is the
>
> same param name
>
> either way — the contract owns the typed shape, and
>
> membersRoute.parsePath(c.req.param())
>
> in Step 3 bridges the runtime params the service router collected into that typed shape. You write the pattern once, in the contract.

## 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:

```ts
// 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'],
  }],
});
```

**@netscript/service/auth — the route-authz surface**

| 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:

```ts
// 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.

> Health stays public
>
> By default
>
> .withAuthn()
>
> protects
>
> /api
>
> and leaves
>
> /health
>
> anonymous — so liveness and readiness probes answer without a credential even under a guarded API prefix. That is why the service's
>
> /health
>
> endpoint kept working through every earlier chapter while
>
> /api/workspace/:workspace/members
>
> is now guarded.

## 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):

```sh
# 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" }
```

```sh
# '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" }
```

```sh
# '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:

```json
{
  "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](https://rickylabs.github.io/netscript/tutorials/workspace/04-provision-job/) job). A `401` or `403` means the request never reached the query at all.

> Route-Level Scope Authorization Boundary
>
> The
>
> .withAuthz()
>
> helper acts as a route-level filter that evaluates flat scope strings attached to the
>
> Principal
>
> . This design boundary separates HTTP route gating from complex tenant-ownership check logic. The framework does not automatically evaluate role hierarchies (such as admin permission inheritance) or verify organization-specific boundaries (such as confirming the caller is a member of the requested
>
> workspace
>
> ). Currently, you must perform tenant-membership checks manually within your queries. Integrating typed organization helpers and plugin-aware principal mapping is planned under roadmap items R3 and R5.

## 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.ts` exports `membersRoute`, a bound route contract with a typed `{ workspace }` param and pagination search.
- [ ] `services/workspace/src/auth.ts` defines the authenticator and a scope authorizer.
- [ ] The `workspace` service registers `GET /api/workspace/:workspace/members` and applies `.withAuthn()` and `.withAuthz()`.
- [ ] The handler reads params via `membersRoute.parsePath(...)` / `.parseSearch(...)`, not by hand.
- [ ] An unauthenticated request returns `401 UNAUTHORIZED` (`missing-credential`).
- [ ] `Bearer write` returns `403 FORBIDDEN` (`authz.missing-scope:workspace:read`).
- [ ] `Bearer read` returns `200` with a body carrying `workspace`, `limit`, and `subject`.
- [ ] `GET /health` still 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](https://rickylabs.github.io/netscript/tutorials/workspace/06-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](https://rickylabs.github.io/netscript/tutorials/live-dashboard/04-definePage-QueryIsland/).
- **Go deeper on identity.** The [auth plugin guide](https://rickylabs.github.io/netscript/tutorials/workspace/02-auth/) covers resolving real human sessions into the `Principal` this guard authorizes.

[4 · Provision job](https://rickylabs.github.io/netscript/netscript/tutorials/workspace/04-provision-job/) [6 · Deploy](https://rickylabs.github.io/netscript/netscript/tutorials/workspace/06-deploy/)
