Kwisple
Browse the docs

Apps: custom modules per client

Example: a generator tool

The RANSON.N Golfgenerator, a brand tool without platform data ported from a Craft project, and the general recipe for any client-specific tool.

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

FileRole
cms.config.tsDeclares the app golfgenerator (area brand, level edit).
src/app/(beheer)/beheer/golfgenerator/page.tsxThe app's page at the declared path.
src/components/beheer/golfgenerator.tsxAsks the site for the tool's address, then shows the tool in a frame of its own.
src/app/api/beheer/golfgenerator/route.tsPOST: verifies the app session and returns the tool's address with a 10-minute ticket.
src/app/(beheer)/beheer/golfgenerator/tool/route.tsGET: serves the tool, only with a valid ticket.
src/beheer/golfgenerator.htmlThe tool: one HTML file with its own CSS and script, ported from Craft.
src/lib/beheer.tsThe shared helper (beheer), plus maakTicket and ticketGeldig.
next.config.tsIncludes 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-store and noindex keep the tool out of caches and search engines.

  • Framing a nested page. Browsers check frame-ancestors against 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 from next.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 same frame-ancestors as 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:

  1. Copy the page as one file. The HTML, its <style> and its <script> live in src/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.
  2. Serve it from a route that checks a ticket, as above, instead of a Craft template behind a Craft login.
  3. Wrap it in an app page that connects to the dashboard and fetches the ticket.
  4. 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.
  5. 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.

  1. 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).
  2. Declare it in cms.config.ts with defineApp({ handle, name, description, icon, path, area, level }), add it to defineSchema({ apps }) and run kwisple push (Declaring an app). Use teamOnly: true for agency-only tooling.
  3. Give the app routes a bare layout (a route group with its own root layout and noindex), and make sure the site sends frame-ancestors with the dashboard origin and no X-Frame-Options for them (Framing requirements).
  4. Connect in the browser once with connectApp({ dashboardOrigin }) from @kwisple/sdk/app; show a loading state and a calm message when ready rejects.
  5. Send every request through app.fetch to routes of your own site.
  6. Verify every request on the server with appSessionFrom(request, { secret, app }) (or appPlatform when you need submissions), then gate actions with appLevelAtLeast(session.level, …). Return what the person may do along with the data.
  7. 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.
  8. Use the site's token for content through createClient on the server, gated by the session's level, never in the browser.
  9. 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.

Last updated 2026-10-30