Not every app touches platform data. The Golfgenerator of RANSON.N draws the pixel wave from the business's brand book and lets the team download it as PNG or SVG in any format. It reads nothing from the platform and writes nothing back. What the platform gives it is the place in the menu, the knowledge of who is using it, and the guarantee that only people with the right level can open it.
The tool already existed as a self-contained HTML page in the business's former Craft CMS project. This page shows how it came into the dashboard, then gives the general recipe.
The pieces
| File | Role |
|---|---|
cms.config.ts | Declares the app golfgenerator (area brand, level edit). |
src/app/(beheer)/beheer/golfgenerator/page.tsx | The app's page at the declared path. |
src/components/beheer/golfgenerator.tsx | Asks the site for the tool's address, then shows the tool in a frame of its own. |
src/app/api/beheer/golfgenerator/route.ts | POST: verifies the app session and returns the tool's address with a 10-minute ticket. |
src/app/(beheer)/beheer/golfgenerator/tool/route.ts | GET: serves the tool, only with a valid ticket. |
src/beheer/golfgenerator.html | The tool: one HTML file with its own CSS and script, ported from Craft. |
src/lib/beheer.ts | The shared helper (beheer), plus maakTicket and ticketGeldig. |
next.config.ts | Includes the HTML file in the server build. |
Declaring it
// cms.config.ts
const golfgenerator = defineApp({
handle: "golfgenerator",
name: "Golfgenerator",
description: "De pixelgolf uit het brandbook, als PNG of SVG in elk formaat.",
icon: "waves",
path: "/beheer/golfgenerator",
area: "brand",
level: "edit",
});The area decides who sees it. A brand tool belongs in brand (Merk), whose levels are
Verborgen, Bekijken and Bewerken. With level: "edit" a client user needs
Merk → Bewerken; someone who may only look at the brand kit does not get the generator.
Use level: "view" if looking at the brand is enough to use the tool. The agency team always
opens it.
Because the tool reads no platform data, the area has no effect on what the app can do, only on who sees it. Pick the area a business owner would expect: the people who manage the brand are the people who make brand artwork.
Why a second frame and a ticket
The tool is a full HTML document with its own <style>, fonts and script. Rendering it inside
a React page would mix its CSS with the site's. So the app page shows it in a nested
<iframe>:
dashboard ─frame─▶ /beheer/golfgenerator (AppSessie + Golfgenerator component)
└─frame─▶ /beheer/golfgenerator/tool?t=<ticket> (the Craft tool)That creates a problem: an <iframe src> request cannot carry an Authorization header, so
the tool route cannot check the app session. The solution is a short-lived ticket: the page
asks the site's API (with the session) for the tool's address, the API answers with a URL that
carries a signed ticket, and the tool route checks that ticket instead.
The page and the component
The page is the same as the booking desk's, with the generator inside the session wrapper:
// src/app/(beheer)/beheer/golfgenerator/page.tsx
import { AppSessie } from "@/components/beheer/app-sessie";
import { Golfgenerator } from "@/components/beheer/golfgenerator";
import { DASHBOARD_ORIGIN } from "@/lib/beheer";
/** App "Golfgenerator" in the dashboard (cms.config.ts → apps). */
export default function GolfgeneratorPage() {
return (
<AppSessie dashboardOrigin={DASHBOARD_ORIGIN}>
<Golfgenerator />
</AppSessie>
);
}
export const dynamic = "force-dynamic";The component asks for the tool's address once, then renders the frame:
// src/components/beheer/golfgenerator.tsx
"use client";
import { useEffect, useState } from "react";
import { useAppSessie } from "./app-sessie";
/** The generator in a frame of its own, so its styles and the page's never mix. */
export function Golfgenerator() {
const { app } = useAppSessie();
const [tool, setTool] = useState<string | null>(null);
const [fout, setFout] = useState(false);
useEffect(() => {
app
.fetch("/api/beheer/golfgenerator", { method: "POST" })
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(String(r.status)))))
.then((b: { tool: string }) => setTool(b.tool))
.catch(() => setFout(true));
}, [app]);
if (fout)
return <p className="p-8 text-muted">De golfgenerator kon niet geladen worden. Herlaad de pagina.</p>;
if (!tool) return <p className="p-8 text-muted" role="status">Even laden…</p>;
return (
<iframe
src={tool}
title="RANSON.N golfgenerator"
className="block h-screen w-full border-0"
/>
);
}app.fetch attaches the session as Authorization: Bearer <token>. The request uses POST
so no cache between the page and the site ever stores a ticket.
The session check: POST /api/beheer/golfgenerator
// src/app/api/beheer/golfgenerator/route.ts
import { beheer, maakTicket, nietAangemeld } from "@/lib/beheer";
/** POST — the address of the generator for whoever opened the app (a ticket of 10 minutes). */
export async function POST(request: Request) {
if (!(await beheer(request, "golfgenerator"))) return nietAangemeld();
return Response.json({ tool: `/beheer/golfgenerator/tool?t=${maakTicket("golfgenerator")}` });
}beheer() is the site's wrapper around appPlatform (see
Server side). appPlatform verifies the session the same
way appSessionFrom does and only contacts the dashboard when you call one of its methods, so
using it here costs nothing extra. For a tool that never needs platform data, appSessionFrom
is the plainer choice:
import { appLevelAtLeast, appSessionFrom } from "@kwisple/sdk";
export async function POST(request: Request) {
const session = await appSessionFrom(request, {
secret: process.env.KWISPLE_WEBHOOK_SECRET,
app: "golfgenerator",
});
if (!session) return Response.json({ fout: "Open dit via het dashboard." }, { status: 401 });
if (!appLevelAtLeast(session.level, "edit")) return new Response(null, { status: 403 });
return Response.json({ tool: `/beheer/golfgenerator/tool?t=${maakTicket("golfgenerator")}` });
}The level check is redundant with this declaration, but keeps the route correct if it changes.
appSessionFrom returns null when the header is missing, the signature does not match the
site's KWISPLE_WEBHOOK_SECRET, the session has expired, or it was issued for another app of
the site. A Boekingen session can therefore never fetch a Golfgenerator ticket, and the other
way round.
The ticket
The ticket is RANSON.N's own code, not a platform API. It is an expiry time plus an HMAC over the app handle and that time, under a key derived from the webhook secret for this purpose only:
// src/lib/beheer.ts
import { createHmac, timingSafeEqual } from "node:crypto";
const SECRET = process.env.KWISPLE_WEBHOOK_SECRET;
export function maakTicket(app: string, minuten = 10): string {
const exp = Math.floor(Date.now() / 1000) + minuten * 60;
return `${exp}.${teken(app, exp)}`;
}
export function ticketGeldig(ticket: string | null, app: string): boolean {
const [exp, sig] = (ticket ?? "").split(".");
const n = Number(exp);
if (!SECRET || !sig || !Number.isInteger(n) || n < Date.now() / 1000) return false;
const a = Buffer.from(teken(app, n));
const b = Buffer.from(sig);
return a.length === b.length && timingSafeEqual(a, b);
}
function teken(app: string, exp: number): string {
return createHmac("sha256", `ranson-beheer-ticket:${SECRET}`)
.update(`${app}.${exp}`)
.digest("base64url");
}Properties worth copying:
- Its own key. The
ranson-beheer-ticket:prefix makes the key different from the one the platform uses for app sessions and from the raw secret used for publish webhooks, so a ticket can never pass for either, and neither for a ticket. - Bound to the app. The handle is inside the signature; a ticket for one tool does not open another.
- Short. Ten minutes is enough to load the tool. The tool runs entirely in the browser after that, so an expired ticket does not interrupt someone who is working.
- Constant-time comparison with
timingSafeEqual, after a length check.
What this ticket does not do: it is not bound to a person, and it travels in a URL. Anyone who
gets hold of the URL within ten minutes can load the tool. That is acceptable for a generator
that shows nothing private. For anything that returns personal data, bind the ticket to
session.user.id, keep it to a minute, or avoid URLs entirely and fetch the data with
app.fetch instead.
Serving the tool: GET /beheer/golfgenerator/tool
// src/app/(beheer)/beheer/golfgenerator/tool/route.ts
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { ticketGeldig } from "@/lib/beheer";
// The generator itself: one self-contained page (canvas, PNG/SVG download), ported from Craft.
const HTML = readFileSync(join(process.cwd(), "src/beheer/golfgenerator.html"), "utf8");
/** GET ?t=<ticket> — only with a fresh ticket from the app (never indexed, never cached). */
export function GET(request: Request) {
const ok = ticketGeldig(new URL(request.url).searchParams.get("t"), "golfgenerator");
return new Response(ok ? HTML : "Open de golfgenerator via het dashboard.", {
status: ok ? 200 : 403,
headers: {
"content-type": ok ? "text/html; charset=utf-8" : "text/plain; charset=utf-8",
"cache-control": "no-store",
"x-robots-tag": "noindex",
"content-security-policy": "frame-ancestors 'self'",
},
});
}Notes:
The HTML file is read from disk at runtime, so it must be in the server build. RANSON.N tells Next.js to include it:
// next.config.ts // The golf generator's page is read from disk at runtime (beheer/golfgenerator/tool). outputFileTracingIncludes: { "/beheer/golfgenerator/tool": ["./src/beheer/**"] },Without this, a standalone build (
output: "standalone") ships without the file and the route throws as soon as it loads.no-storeandnoindexkeep the tool out of caches and search engines.Framing a nested page. Browsers check
frame-ancestorsagainst every ancestor, not only the direct parent. The tool page sits inside the site's page, which sits inside the dashboard, so the tool's policy must allow the dashboard's origin too. In RANSON.N the site-wide header fromnext.config.ts(frame-ancestors 'self' <dashboard>) is already on the response, and Next.js keeps a header that the config already set over the one the route returns. If you serve a nested page from another framework or host, give it the sameframe-ancestorsas your app pages.
Downloads from inside the dashboard
The tool saves its PNG and SVG with a temporary object URL and an <a download> click:
function download(name, blob) {
const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = name;
// …
}
$('dl-svg').onclick = () => download(name('svg'), new Blob([buildSVG()], { type: 'image/svg+xml' }));This works inside the dashboard because the dashboard's frame allows downloads
(sandbox="… allow-downloads …"), and a nested frame inherits that. Clipboard writes are
allowed too (allow="clipboard-write"). Nothing needs the network: the generator draws on a
canvas and builds the SVG as a string, with the house fonts served by the site itself.
Porting a tool from a Craft project
The generator came from the brand tool of the business's Craft project. The same steps apply to any self-contained page you bring over:
- Copy the page as one file. The HTML, its
<style>and its<script>live insrc/beheer/golfgenerator.html, with the house fonts loaded from the site's own/fonts/(no third-party requests). Twig tags and Craft variables have to go: replace them with static values, or with values your route injects before serving the file. - Serve it from a route that checks a ticket, as above, instead of a Craft template behind a Craft login.
- Wrap it in an app page that connects to the dashboard and fetches the ticket.
- Declare the app with the area and level that match who should use it. On the platform that is decided by the agency's access policy, not by a list of users in the site.
- Leave the old login behind. Who may use the tool is decided by the dashboard. Do not keep a password or a CMS session next to the app session.
If the tool needs content (texts, images, settings an editor maintains), read it with the site's API token on the server and inject it into the page, or expose it through a route that checks the session. Do not give the browser the site token.
The general recipe for a client-specific tool
The same skeleton fits a calculator, an export, a label printer or a generator.
- Decide that it is an app. It serves one client and its logic belongs to their site. If every client could use it, it is a platform feature instead (Apps overview).
- Declare it in
cms.config.tswithdefineApp({ handle, name, description, icon, path, area, level }), add it todefineSchema({ apps })and runkwisple push(Declaring an app). UseteamOnly: truefor agency-only tooling. - Give the app routes a bare layout (a route group with its own root layout and
noindex), and make sure the site sendsframe-ancestorswith the dashboard origin and noX-Frame-Optionsfor them (Framing requirements). - Connect in the browser once with
connectApp({ dashboardOrigin })from@kwisple/sdk/app; show a loading state and a calm message whenreadyrejects. - Send every request through
app.fetchto routes of your own site. - Verify every request on the server with
appSessionFrom(request, { secret, app })(orappPlatformwhen you need submissions), then gate actions withappLevelAtLeast(session.level, …). Return what the person may do along with the data. - For requests that cannot carry a header (frames, downloads, images), mint a short ticket from a verified request under a key of its own, and check it in the target route.
- Use the site's token for content through
createClienton the server, gated by the session's level, never in the browser. - Test from the dashboard as a team member and with Bekijk als klant (view as client); open the page's URL directly once and check that it refuses.
A minimal route following steps 6 and 8:
import { appLevelAtLeast, appSessionFrom } from "@kwisple/sdk";
export async function POST(request: Request) {
const session = await appSessionFrom(request, {
secret: process.env.KWISPLE_WEBHOOK_SECRET,
app: "prijscalculator",
});
if (!session) return new Response(null, { status: 401 });
if (!appLevelAtLeast(session.level, "edit")) return new Response(null, { status: 403 });
// … the tool's own work, for session.workspace and session.user
return Response.json({ ok: true }, { headers: { "cache-control": "no-store" } });
}The security reasoning behind each step is on the next page, Security model.