Gateway apps
Build, publish, and install MDK apps with the mdk-app.yaml manifest, the mdk-plugin.json route table, and a UI module
Overview
The Gateway serves HTTP routes from apps. An app is a package with an mdk-app.yaml manifest, a route table
(mdk-plugin.json, in plugin/ or at the package root) and one controller file per route. Each app runs in its own process
under @tetherto/mdk-app-runtime: the Gateway reads only the route table and forwards each request to
that runtime over HRPC, so no app code runs inside the Gateway. A Gateway serves exactly the apps your stack declares in
spec.apps[] and nothing else.
An app builds its own @tetherto/mdk-client to call into the Kernel. No knowledge of the MDK
Protocol envelope or internal message shapes is required.
Prerequisites
- The
mdkCLI is installed, with a projectmdk.yaml(mdk onboardwrites one)
Apps MDK ships
Each app MDK ships lives in its own repository, listed in the supported apps reference, and its README documents the routes it serves. Declare one by package name:
# mdk.yaml
spec:
gateway:
port: 3847
apps:
- name: demo
package: "@tetherto/mdk-app-demo"A stack serves only the apps it declares. Any other path answers 404. If a React adapter hook reads a
route, declare the app that serves it.
Third-party apps are documented by their own manifests. mdk add app <source> installs one from npm or GitHub, checks it
with the same rules as mdk validate app, and only then adds it under spec.apps.
Create an app
mdk create app fleetThis scaffolds apps/fleet, links it into the project's package.json, and adds it under spec.apps in mdk.yaml
(--no-stack-entry prints the entry instead). For an app that lives in its own repo and installs with
mdk add app github:<owner>/<repo>#<tag>, run mdk create app fleet --flat in the repo instead: it scaffolds at the repo root and touches no
project. The scaffold carries one read-only summary route and a starter UI that shows it:
apps/fleet/
├── mdk-app.yaml # app manifest
├── package.json # `files` ships the manifest, plugin/, ui/ and README.md
├── README.md
├── plugin/
│ ├── mdk-plugin.json # route table
│ ├── lib/client.js # the app's own mdk-client
│ ├── types.d.ts # response types, shared with the UI
│ └── controllers/summary.js # one module per route
├── ui-src/ # UI source with its own package.json and Vite build, not published
└── ui/index.js # the built UI module, committed; a placeholder until `npm run build:ui`mdk-app.yaml describes the app to the operator: its id, version, name, the Kernel permissions it asks for, default
config and optional background main. Every field is in the app runtime's manifest reference. Keep
version equal to the package.json version; mdk validate app checks it.
The Kernel enforces spec.rbac on every call the app makes. mdk add app grants the app's service account (app:<name>)
exactly these permissions, as a role in mdk.yaml the operator may narrow, so request every Kernel resource the app
reads or writes: a call outside them throws ERR_KERNEL_ACTION_FORBIDDEN, which the route answers as the app runtime
sends it.
Check it with mdk validate app fleet; what it checks is in the CLI reference.
Write the routes
2.1 Edit the route table
mdk-plugin.json declares the plugin identity (name, version) and a routes array. Each route needs an id, a
handler path, and either an http block with a method and path, or those same method/path fields flattened to the route's top level;
the agent app uses the flat form. A table that declares "routeTableVersion": "1", as mdk create app writes it, is held
to the route-table schema's OpenAPI-shaped rules as errors by mdk validate app; a table without it gets
them as warnings. Rather than copy a synthetic example, start from a real manifest and trim it:
examples/mvp-site/backend/gateway-plugins/site/mdk-plugin.json:GETroutes with query parameters, andPOSTroutes with arequestBodyand path parametersmdk-app-demo/mdk-plugin.json: read-onlyGETroutes with query parameters, each annotated withsafetymdk-app-agent/mdk-plugin.json:auth: trueroutes in the flat form, one of them a stream route
The demo and agent apps live in their own repositories; the mvp-site manifest is the one in this
repository.
Path parameters use {param} syntax; the loader normalizes them to Fastify's :param format. For named exports use "handler": "./controllers/foo.js#namedExport". Every field is listed in the route-table field reference, generated from
the schema the app runtime publishes. The loader enforces the fields at boot; what it refuses, and
why is app-runtime's ERR_PLUGIN_MANIFEST_INVALID row.
Give each route a description that says what it does and when to call it. It becomes the description of the MCP tool derived from the
route, and the route's Purpose in an HTTP reference if you generate one; without one, mdk validate app warns,
and a generated reference shows "Not declared".
The Gateway validates a route's schema and requestBody with type coercion: write a
nullable number, string, boolean or array as a type list ("type": ["number", "null"]), not as the anyOf that
z.toJSONSchema writes for a bounded nullable, or a JSON null reaches the controller as 0, "", false or an array.
2.2 Write a controller
A controller builds its own @tetherto/mdk-client once, from the app's
context config, in a lib/client.js every controller in the app requires.
Every controller exports an async function (req):
// controllers/live.js — read live telemetry
const mdkClient = require('../lib/client')
module.exports = async function live (req) {
const deviceId = req.query.deviceId
const telemetry = await mdkClient.pullTelemetry(deviceId, 'metrics')
return { deviceId, ...telemetry }
}A write a user asks for goes through req.mdk, the request's Kernel client. It acts for the signed-in user, so the Kernel checks
their own spec.rbac rules as well as the app's, and the write-command log records them via the app:
// controllers/command.js — dispatch a command for the signed-in user
module.exports = async function command (req) {
const deviceId = req.params.deviceId
const { mode } = req.body
const result = await req.mdk.sendCommand(deviceId, 'setPowerMode', { mode })
return {
deviceId,
commandId: result.commandId,
status: result.status
}
}Declare and mount it
mdk create app already wrote this entry; a hand-written one has the same shape:
spec:
apps:
- name: fleet # ^[a-z][a-z0-9-]*$, unique in the stack
package: fleet # npm name or local path
config: {} # optional: overrides the mdk-app.yaml config defaults
mount: / # optional: serve the routes at the root instead of /apps/fleetThe Gateway serves an app's routes under /apps/<name> unless mount is set, so the scaffold's GET /api/fleet/summary
answers at /apps/fleet/api/fleet/summary, or at /api/fleet/summary with mount: /. Mount an app at the root only when a
client calls its paths there, as the agent app needs for its UI.
Run it
mdk run # Kernel, Workers, one runtime process per app, then the Gateway
mdk run app fleet # or only this app, in the foreground, beside `mdk run kernel` and `mdk run gateway`The runtime loads the route table and every handler before it serves, so a missing file or invalid manifest fails that app
at startup. It accepts calls only from the Gateway's key (.mdk/gateway.key); until it is up, its routes answer 503.
No route is open, so sign in and send the access token to reach
http://localhost:3847/apps/fleet/api/fleet/summary.
Exposing an app's routes to the operator agent turns them into MCP tools with no separate manifest.
The app's UI module
An app's routes are one half of it, and this is the other. ui/index.js default-exports defineMdkApp from
@tetherto/mdk-react-adapter, which types the contract a shell loads:
// ui-src/index.tsx, built to ui/index.js
export default defineMdkApp({
id: 'com.example.fleet',
displayName: 'Fleet',
routes: [{ path: '', component: Overview }],
panels: [{ id: 'drift', slot: 'home', component: DriftCard }]
})mdk create app scaffolds ui-src/ with one page and one Home panel, plus a placeholder ui/index.js that
npm run build:ui replaces.
Share response types with the UI
Controllers are CommonJS JavaScript: the app runtime loads .js and .cjs from plugin/, so a TypeScript controller
must be compiled to one first. Type a route's response once, in plugin/types.d.ts, and check both halves against it. The
controller names the type with JSDoc under // @ts-check, and the UI imports it with import type, which the UI build erases:
// plugin/controllers/summary.js
// @ts-check
/** @typedef {import('../types').AppRequest} AppRequest */
/** @typedef {import('../types').Summary} Summary */
/**
* @param {AppRequest} req
* @returns {Promise<Summary>}
*/
module.exports = async function summary (req) { /* ... */ }// ui-src/data/summary.ts
import type { Summary } from '../../plugin/types'AppRequest in the same file types the req every controller receives, so new controllers do not re-type it.
The scaffold wires the summary route this way, and npm run typecheck checks both halves, so renaming a field fails the
type check in the controller and in every component that reads it.
| Field | Contains |
|---|---|
id | The app's reverse-DNS id. Must equal id in mdk-app.yaml: the shell checks it against the installed app's id, so a package cannot load under another app's identity |
displayName | The name the shell shows, and the fallback for a route that sets no label or a panel that sets no title |
routes | Pages the shell mounts under /app/<name>/, in the app's own sidebar group. path is relative, '' being the landing page; label sets the sidebar label; section groups the page with the others naming it; hidden routes a deep link without listing it |
sections | Optional. An icon for a route section: label is the section its routes name, icon a component the shell renders with no props. A section works with no entry here, which only adds what a label alone cannot |
panels | Components the shell composes into pages it owns. slot picks where: every MDK shell provides MDK_APP_PANEL_SLOT.HOME, and a shell ignores slots it does not know |
The dashboard serves its app list at /app-ui.json, written from the stack's mdk.yaml by mdk run dashboard. It imports
each app's ui/index.js, checks it against the contract, and adds its stylesheets to the page, so installing an app needs no
dashboard change: restart the dashboard and the app's pages and panels are there. An app whose UI fails to load is reported
on its own instead of taking the dashboard down.
A built UI module imports React and the MDK packages by bare specifier, and the dashboard resolves those to its own instances through an import map. A shell that does not map them fails to load every app UI.
Publish and install an app
An app is an npm package, so shipping one is npm publish or a Git tag. What makes it installable is what the tarball
carries and what the consumer runs.
Package it
package.json files has to ship mdk-app.yaml, plugin/ and ui/. Commit the built ui/ rather than building on
install: a GitHub install runs no build, so an unbuilt ui/ reaches the consumer as a missing module.
{
"files": ["mdk-app.yaml", "plugin/", "ui/", "README.md"]
}Run mdk validate app <name> before publishing or tagging.
Publish it
Either source works, and an app does not have to reach npm at all:
git tag v0.2.0 && git push --tags # install from GitHub at that tag
npm publish --access public # or publish under your own scopeInstall it
A consumer adds the app to their stack from either source, then starts it:
mdk add app @your-org/mdk-app-fleet@^1
mdk add app github:your-org/mdk-app-fleet#v0.2.0 --name fleet
mdk runmdk add app installs with npm install --ignore-scripts --save-exact, so no install script of an unchecked package
runs and the exact version is recorded. For a GitHub source, that's the commit the lockfile resolved, written into
package.json as github:<owner>/<repo>#<sha>. It then checks the package with the same rules as mdk validate app
and only then writes the spec.apps entry, keeping the stack file's comments and formatting. A refused package leaves
mdk.yaml, package.json and the lockfile as they were. The CLI reference covers --name, repeat installs,
and the worker equivalent.
A GitHub source cannot be added with --no-install: its package name is only known once npm has fetched it.
Declare what it needs
An app states its own config defaults under config in mdk-app.yaml, and the operator overrides them per stack. Supplying
per-app config covers the layering.
Start apps from code
Outside the CLI, pass each app to startGateway() from @tetherto/mdk-core as apps (the complete list, nothing else is
mounted) and run each one with createAppRuntime(), allowing only the Gateway's key:
const path = require('path')
const { startGateway } = require('@tetherto/mdk-core')
const { createAppRuntime } = require('@tetherto/mdk-app-runtime')
const appDir = path.join(__dirname, 'apps/fleet')
const runtime = createAppRuntime({
name: 'fleet',
appDir,
kernelKey: kernel.getPublicKey(),
storeDir: './run/apps/fleet',
keyFile: './run/fleet.key',
allowedKeyFiles: ['./run/gateway.key'],
kernelKeyFile: './run/fleet.kernel.pub'
})
await runtime.start()
kernel.allowKey(runtime.getKernelPublicKey())
await startGateway({
kernel,
port: 3000,
gatewayKeyFile: './run/gateway.key',
apps: [{ name: 'fleet', appDir, keyFile: './run/fleet.key' }]
})The runtime's Kernel key must be admitted before its controllers call the Kernel, through kernel.allowKey(), the Kernel's
auth.whitelist, or the trusted-keys directory. startGateway()'s apps reference lists the validation errors,
and @tetherto/mdk-app-runtime the runtime options. startGateway() throws
ERR_GATEWAY_EXTRA_PLUGIN_DIRS_UNSUPPORTED when passed extraPluginDirs.
The req object
A controller's only argument. The controller reference documents every field
(params, query, body, headers, _info) and how it's assembled.
The app's context module
require('@tetherto/mdk-gateway/plugin') resolves, inside an app's controllers, to that app's own frozen context. The
controller reference shows a controller building its own client from it:
| Field | Type | Contains |
|---|---|---|
config | object | The mdk-app.yaml config defaults with the app's spec.apps[].config layered over them key-by-key, plus mdkClient: the app runtime's Kernel client, signed in as the app's service account (app:<name>), so every call is checked against spec.rbac |
logger | object | The app runtime's logger, namespaced mdk:app:<name> |
onReady | function | Registers a callback that runs once the app runtime is serving, or straight away if it already is. Errors thrown inside it are caught and logged, not fatal |
Supplying per-app config
That config isn't declared in mdk-plugin.json. Defaults live under config in mdk-app.yaml, and the stack spec overrides
them per app with spec.apps[].config (createAppRuntime({ config }) from code):
spec:
apps:
- name: custom-metrics
package: ./apps/custom-metrics
config:
apiKey: ${METRICS_API_KEY}Build a lib/client.js from it once per app and require that module from every controller that
needs one — there is no per-request Kernel access to guard, only the client's own connect failures:
createMdkClient connects on first use and memoizes the connection. A failure maps to ERR_MDK_CLIENT_UNAVAILABLE (or your own
opts.errorCode) and resets so the next call retries — guard the call, not a null client:
try {
return await mdkClient.pullTelemetry(deviceId, 'metrics')
} catch (err) {
if (err.message === 'ERR_MDK_CLIENT_UNAVAILABLE') throw new Error('ERR_KERNEL_UNREACHABLE')
throw err
}Read hardware data
Call the client directly for live device data — pullTelemetry, getCapabilities, and listWorkers
are documented with their return shapes in the client's own reference.
A fleet-wide number, hashrate across every miner on site say, must never start a second hardware polling loop. Workers
whose contract declares telemetryPollIntervalMs already poll their devices and store the series, so read the fleet
from those stores in one call with pullSnapshot, or let the Workers compute the number with
pullAggregate:
const { workers } = await mdkClient.listWorkers()
const workerIds = workers.map((w) => w.workerId)
const snap = await mdkClient.pullSnapshot({ workers: workerIds, metrics: ['hashrate_rt'] })
const values = Object.values(snap.devices).map((d) => d.metrics.hashrate_rt?.value).filter(Number.isFinite)
const totalHashrate = values.reduce((sum, v) => sum + v, 0)
const agg = await mdkClient.pullAggregate({ workers: workerIds, ops: { hashrate_total: { op: 'sum', src: 'metrics.hashrate_rt' } } })
// agg.ops.hashrate_total, agg.deviceCount, agg.errors (Workers that could not answer)An op groups or filters on a metric, on deviceId, or on a label the operator gave the device in mdk.yaml under
config.devices[].info (group: 'info.container'): the labels are the only part of a device's configuration an app
can read this way. A Worker reported under errors with ERR_TELEMETRY_STORE_DISABLED does not poll; read it live
with pullTelemetry per device, or ask its operator to set telemetryPollIntervalMs.
Read deviceCount and errors before you treat a number as a fleet total. deviceCount counts the devices whose
stored point held a reading, so a listed Worker that is registered with no devices answers, adds nothing to
deviceCount and leaves every op at its empty value (a sum of 0). ops is {} only when no listed Worker
answered at all, and each of those is then under errors. A Worker with no devices resolves to no device name, so a
caller whose devices read rule is narrowed with resourceNames is refused for a list that names it
(ERR_KERNEL_ACTION_FORBIDDEN); only an unscoped rule covers it.
The Kernel checks devices read for every device of each listed Worker, so an app that reads the fleet requests it
in mdk-app.yaml beside the scaffold's workers list:
permissions:
- { resources: [workers], verbs: [list] }
- { resources: [devices], verbs: [read] }Without the rule the call throws ERR_KERNEL_ACTION_FORBIDDEN, which the route answers as the app runtime sends
it, with that message. A
Worker that collects its own telemetry is left out of the Kernel's scheduled pull, so nothing polls its devices twice.
pullTelemetry(deviceId, 'metrics') still reads one device live when the question is about that device right now.
demo/controllers/summary.js is a small worked example of a fleet read, worth starting from if
you're authoring your own app.
There is no separate Gateway-side store for historical or aggregated data, either. Read the series from each Worker's own
persisted tail-log with pullTelemetry, addressed to the device whose history you want:
const history = await mdkClient.pullTelemetry(deviceId, { type: 'logs', key: 'stat-1D', tag: 't-miner', start, end })The Kernel authorizes a pullTelemetry call as devices read on the device it addresses and does not inspect the
query; what comes back is decided by the Worker. These store-backed query types exist on a Worker that runs the
Worker Runtime's store services (in this repo, the container and temperature Workers); a
WorkerRuntimeV2 Worker keeps its history in its telemetry store and answers pullSnapshot with from instead.
| Query type | What a read addressed to one device returns |
|---|---|
logs with key: 'thing-5m', logs_multi, historical_logs | The addressed device's own snapshot series, alerts and info changes; the tag and deviceIds fields cannot point it at another device |
logs with a stat-* key | The series the Worker stores for tag, aggregated over every device carrying that tag; a tag the addressed device does not carry is refused with ERR_LOG_TAG_FORBIDDEN |
list, stats, thing_config | The addressed device only |
count, config, settings, ext_data | Worker-level facts: the device count, the contract, the settings or the pool data, whichever device you address |
To read a Worker's own records rather than one device's, ask the Worker: pullWorkerQuery
runs the query on each listed Worker with no device addressed and answers per Worker, and the Kernel checks devices
read on every device of each listed Worker, as it does for pullSnapshot:
const { workers: perWorker, errors } = await mdkClient.pullWorkerQuery({ workers: [workerId], type: 'list', status: true, limit: 200 })
// perWorker[workerId].things: every device of the Worker with its latest snapshot; errors?.[workerId] when it could not answer
const series = await mdkClient.pullWorkerQuery({ workers: [workerId], type: 'logs', key: 'stat-1D', tag: 't-miner', start, end })A stat-* log is shared: a response can include figures from devices the caller cannot address, and a caller scoped
to one device still reads the shared total (what resourceNames does and does not bound). Do
not treat one device as standing for its whole Worker: a device-addressed read answers for that device. A Worker
with no current devices still has its retained history; read it with pullWorkerQuery, which for a Worker with no
devices only an unscoped devices read rule allows.
demo/controllers/history.js is the smallest worked example — a single-file version with an
optional per-device filter and a Kernel-unavailable fallback.
Both live and historical calls are network calls through the client — guard both the same way. An
unguarded pullTelemetry throws ERR_MDK_CLIENT_UNAVAILABLE straight through to the caller as an
unhandled 500 the moment the Kernel drops. When only a Worker drops, pullTelemetry does not throw: it answers { error }
(ERR_WORKER_UNREACHABLE, ERR_WORKER_NOT_ROUTABLE or ERR_DEVICE_NOT_FOUND), so check res.error before the field you
read. A Worker that hangs instead can still time the request out with ERR_MDK_REQUEST_TIMEOUT.
Send a command
sendCommand dispatches via the Kernel to the Worker that owns the device — the command
must be declared in the Worker's mdk-contract.json. controllers/command.js above shows the pattern; return shape
is commandId, status, result, error.
Caching
Add a "cache" array of dot-path strings to a route to enable request-level caching. The key is the route id plus those values, so a route
whose answer depends on the caller adds an identity path such as _info.user.userId. On an auth: true route the
auth check runs before the cache lookup. Who may force a refresh with ?overwriteCache=true is in the
Gateway's security model.
The manifest reference shows the field in a real manifest.
Stream routes
Add "stream": true to a route to own the raw ServerResponse instead of returning a plain value — for SSE or any
other response Fastify shouldn't serialize. The manifest reference covers the mechanism and the
handler's error behavior. mdk-app-agent is a shipping example — its message route
streams text/event-stream this way; see the agent Gateway-deployment guide for the consumer side.
Auth and permissions
Every route of an app is checked against spec.rbac at the Gateway: the caller needs a bearer access token (POST /auth/login
issues one) and a rule granting the route's resource and verb. Without a token the Gateway answers 401 ERR_AUTH_UNAUTHORIZED,
and without a rule 403 ERR_AUTH_FORBIDDEN, before body validation, the cache and your controller. Who may call the route is the
operator's to set in mdk.yaml; the manifest only says how the route is governed:
| Route field | Status | Description |
|---|---|---|
verb | Optional | get, list, create, update or delete. Without it, the verb comes from the HTTP method, as the Gateway's security model maps it |
resourceName | Optional | { "param": "<name>" }, { "query": "<name>" } or { "body": "<name>" }: where the instance a rule's resourceNames matches comes from |
A route's auth and permissions fields aren't read here: they're input to the authVerifier fallback below, so a permissions
list gates nothing under spec.rbac. mdk validate app warns about a route that sets permissions, and the Gateway logs each such route at boot.
The resource is <app>/<path>: the app's name in spec.apps and the route's path template, so the route /miners/:id of
ops-center is ops-center/miners/:id. A rule for it:
spec:
rbac:
roles:
- name: rack3-tech
rules:
- resources: [ops-center/miners/command]
verbs: [create]
resourceNames: ["rack-3-*"] # matched against the route's resourceName field
bindings:
- role: rack3-tech
subjects: [{ kind: User, name: tech@example.com }]The verified identity reaches the controller as req._info, { kind, sub, email?, groups? }, so identity never comes from the
request body. The Gateway's security model lists the rest of the defaults.
Call a route
Sign in for an access token, then send it as a bearer token. The user to sign in as is the first operator
that mdk user add <name> --admin creates:
TOKEN=$(curl -s -X POST http://127.0.0.1:<gateway-port>/auth/login \
-H 'content-type: application/json' \
-d '{"username":"<user>","password":"<password>"}' | jq -er .accessToken)
curl -s -H "Authorization: Bearer $TOKEN" \
http://127.0.0.1:<gateway-port>/apps/<app-name>/api/<domain>/<resource> | jq .The token lasts minutes and the refresh token rotates on every use, so a long-running client signs in again rather than
holding one token. A browser dashboard uses passwordAuth() instead of signing in this way.
A Gateway started with no Kernel and no credential falls back to an authVerifier for routes marked "auth": true:
await startGateway({
kernelKey: false,
gatewayKeyFile: './run/gateway.key',
apps: [{ name: 'fleet', appDir, keyFile: './run/fleet.key' }],
// route is { id, method, path, app, permissions } from the manifest
authVerifier: async (request, route) => {
const token = request.headers.authorization?.replace('Bearer ', '')
if (!token) return false
return { user: { userId: await validateToken(token) } }
}
})The controller then reads the verified identity:
module.exports = async function protectedRoute (req) {
const { sub, groups } = req._info
// Who asked. Kernel calls through req.mdk act for them; config.mdkClient calls act as the app (app:<name>).
}The Gateway forwards that identity to the app runtime with the request, so req._info is set in the app's process too.
A controller's return value always goes out as 200, as JSON: it crosses from the app runtime to the Gateway serialized, so
return plain data (a Buffer arrives as { type: 'Buffer', data: [...] }; send bytes from a stream: true route). A thrown error is shaped by errorResponse, with the status
the app runtime's rule gives it: 500 for an error with no ERR_* code is a bug rather than a bad
request. Attach .statusCode to get a specific
code, the way protectedRoute does here; the bundled agent app uses this same pattern for its 503, 404, and 409 responses. The
body is { statusCode, error, message }, and message carries the error's own text for ERR_* codes and for Fastify's own 4xx
errors (validation, content type, body size); any other message is replaced with the status text and logged.
Manifest validation errors
The app runtime validates the route table and every handler at startup, and the Gateway the route table, and each throws if anything is wrong — see the loader's error codes for the full list.
Next steps
- Watch an app answer an HTTP request with live fleet data in about five minutes
- Try the live site backend example for a complete worked app with three routes: a live site overview, a historical series, and a command endpoint running under PM2 or Docker
- Build the minimal dashboard tutorial — end-to-end worked example of the single-app + controller pattern
- Get the demo app, the smallest complete worked example of both fan-out patterns above
- Understand how Workers declare their data via
mdk-contract.json— whatmdkClientreads andsendCommanddispatches - Look up every route-table field, generated from the schema the app runtime publishes
- Generate an app's HTTP reference and OpenAPI document from its route table, in the app's own repository
- Review the Gateway API and config
- Follow how a request travels and who Kernel admits
- Secure the site with the security blueprint