@tdacorp/hyle-sdk
v0.11.0
Published
hyle SDK: a typed client for hyle's resolve endpoint, a hast renderer for React, and a Next.js catch-all page with tag-based revalidation.
Maintainers
Readme
@tdacorp/hyle-sdk
Mount hyle content in a site with one route. The SDK calls hyle's resolve endpoint (GET /cp1/read/resolve, ADR 0012), renders the page, follows redirects, answers 404, emits metadata and revalidates when hyle publishes.
pnpm add @tdacorp/hyle-sdkMIT licensed. Node 22.12 or later. Semver from 0.1.0 (see CHANGELOG.md); until 1.0 a minor bump may change the API.
Quickstart (Next.js 16, React 19)
Environment (server-side only, never NEXT_PUBLIC_):
HYLE_TARGET_BASE_URL=https://hyle.tdacorp.in
HYLE_TARGET_TOKEN=hyle_tr_... # this workspace's target credential
HYLE_WORKSPACE_ID=<workspace uuid>
HYLE_WEBHOOK_SECRET=<signing secret>app/layout.tsx: import "@tdacorp/hyle-sdk/styles.css";
app/learn/[[...hyle]]/page.tsx:
import { hylePage, hyleMetadata, type HyleNextOptions } from "@tdacorp/hyle-sdk/next";
const options: HyleNextOptions = {
baseUrl: process.env.HYLE_TARGET_BASE_URL!,
token: process.env.HYLE_TARGET_TOKEN!,
workspaceId: process.env.HYLE_WORKSPACE_ID!,
basePath: "/learn", // must equal the workspace's site path prefix in hyle
};
export default hylePage(options);
export const generateMetadata = hyleMetadata(options);app/api/hyle/revalidate/route.ts:
import { createRevalidateRoute } from "@tdacorp/hyle-sdk/next";
export const { POST } = createRevalidateRoute({ workspaceId: process.env.HYLE_WORKSPACE_ID, secrets: [process.env.HYLE_WEBHOOK_SECRET] });app/api/hyle/assets/[file]/route.ts (images, video and audio in hyle content; see "Media"):
import { createAssetRoute } from "@tdacorp/hyle-sdk/next";
export const { GET, HEAD } = createAssetRoute({ baseUrl: process.env.HYLE_TARGET_BASE_URL, token: process.env.HYLE_TARGET_TOKEN });Register the revalidate URL as the workspace's webhook endpoint in hyle. Set metadataBase in your root layout so canonical and hreflang URLs are absolute.
Entry points
| Import | Contents | Needs |
|---|---|---|
| @tdacorp/hyle-sdk | createHyleClient (typed resolve, mounts and asset), listPublicationPaths, checkRoutes and the other route-check functions, Cp1ClientError, verifyHyleSignature, parseSignatureHeader, isValidCacheTag, DeliveryDedupe, createRevalidationCallbackRoute, the result types | nothing (no framework) |
| @tdacorp/hyle-sdk/react | ArticlePage, Comments, ListPage, HyleListView, HyleArticleView, DocsPage, NotFound, Redirecting, Unavailable, HastContent, TrustedServerHtml, sanitizeHast | react >= 19 |
| @tdacorp/hyle-sdk/next | hylePage, hyleMetadata, hyleSitemap, hyleRootPage, hyleRootMetadata, hyleRootSitemap, createRevalidateRoute, createAssetRoute, createEngagementRoute, HyleList, HyleArticle, HyleComments, HyleCommentsFallback, fetchResolver, workspaceTags | next >= 16 |
| @tdacorp/hyle-sdk/next/use-cache | cachedResolver | next >= 16 with cacheComponents: true |
| @tdacorp/hyle-sdk/styles.css | the stylesheet | |
The only runtime dependency is hast-util-to-jsx-runtime (MIT, unified collective, the library behind react-markdown): it maps hast nodes and properties to React elements correctly, which is more than a short hand-rolled function would get right. Everything else is hand-written.
Locale
hyle serves the workspace's default locale unless you pass one. A multilingual site must pass locale:
// app/[locale]/learn/[[...hyle]]/page.tsx
const options = { ..., locale: (params) => String(params.locale), localePrefix: (l) => `/${l}` };hyle's paths and redirect targets never contain your locale prefix. localePrefix(locale) returns the prefix your router uses ("/fr", or "" for a default locale served unprefixed); the SDK puts it back on redirect targets, canonical URLs, hreflang alternates and in-page links. When the requested locale has a published translation at another path, hyle answers a 307 and the SDK issues a temporary redirect(); a 301 (retired slug, non-canonical spelling) becomes permanentRedirect(). When no translation exists the page is shown in the default locale with a "not translated yet" notice (the response's fallback field).
Redirect safety
A redirect target is followed only if it is a same-origin path: one leading /, no //, no backslash, no control character or whitespace. It is checked on hyle's to and again on the final target after localePrefix; anything else raises a Cp1ClientError (INVALID_RESPONSE) instead of redirecting. A locale that is not a plain language tag (en, pt-BR) is treated as unset. A target equal to the current URL is treated as not found, so it cannot loop.
Caching (read this before shipping)
hyle tags every response with cache.tags and, on publish, calls your revalidate route with the tags to purge. Next's fetch cache needs the tags when the request is made, but the response (which carries the tags) does not exist yet. The SDK offers two strategies. Both were run against a real next build and next start (Next 16.3.4) with a mock hyle: a second request is served from cache, a signed webhook purges, the next request refetches.
- Fetch cache (default, no host configuration). The fetch is tagged with what is known beforehand, a fixed and small set whatever URLs visitors request: the workspace tag, the default section
postsand the sections you list (hyle:1:sec:{workspaceId}:{section}). Publish, unpublish, rename and edit all purge the section tag (cp1-spec section 5.8), so the page refreshes without knowing its own publication tag. Passsections: ["docs"]for every other section the mount shows. Limits: a page in a section you did not list, and a page that renders a wikilink to a document in another section, refreshes only whenrevalidate(default 300 s) elapses; the per-publication tags in the response are not used. Redirects and 404s return real HTTP 308, 307 and 404. 'use cache'(exact tags, opt in).cachedResolverfetches inside a'use cache'function and tags the entry with the response's owncache.tags. The host must setcacheComponents: trueinnext.config, and must wrap the page in<Suspense>(Next requires it for route params under Cache Components). Because the page then streams, a redirect reaches the browser as a meta refresh and a not found as a noindex page, both with HTTP 200 (observed). Import it only when opting in: Next compiles the directive inside this package, and withcacheComponentsoff even importing the module fails the build ("To use "use cache", please enable the feature flag cacheComponents", observed), which is why it is a separate entry point.
import { hylePage } from "@tdacorp/hyle-sdk/next";
import { cachedResolver } from "@tdacorp/hyle-sdk/next/use-cache";
const Page = hylePage({ basePath: "/learn", resolver: cachedResolver({ baseUrl, workspaceId, token, revalidate: 300 }) });
export default function Wrapper(props) { return <Suspense fallback={null}><Page {...props} /></Suspense>; }The credential is never part of a cache key: entries are keyed by baseUrl plus workspaceId (pass both to cachedResolver), and the token stays in a module-level map. A switched-off read surface (kind: "disabled") is cached for at most 30 s, so turning it back on is not delayed by the full window.
The revalidate route expires tags immediately (revalidateTag(tag, { expire: 0 })), so no stale page is served after a purge. HYLE_CONTENT_ENABLED=0 (or false) answers 404 from the route, the site-side rollback switch; nothing configured is also 404; partly configured is 503.
List pages
list: { title, description } sets the list page's h1, <title>, description and Open Graph. Each wins over hyle's section title (which is the slug, "Posts") and falls back separately: no list.title keeps the section title, no list.description shows none. Articles are unaffected.
List pages show the first page of results (listLimit, server default 20, maximum 100). paginate: "searchParams" turns on cursor pagination: ?cursor= is read (it must be 1 to 256 characters of A-Za-z0-9_-, otherwise it is ignored; a well-formed cursor hyle rejects is a 404), sent to hyle, and "Older posts" links to ?cursor=<next_cursor>. It is off by default because reading searchParams makes the route dynamic: it can no longer be statically generated or served from ISR (hyle's fetch cache and tags still apply per request). Leave it off when one page is enough. generateMetadata does not read the cursor, so page 2 shares page 1's title.
Errors
How hylePage treats a hyle failure is chosen so a bad moment never replaces a good cached page:
| Failure | Behaviour |
|---|---|
| A 4xx rejection of the request (VALIDATION_ERROR, NOT_FOUND, any 4xx except 401, 403 and 429), a path over 1024 characters, a non-NFC segment | notFound() (a real 404; the last two never reach hyle) |
| Network error, timeout, 5xx, 401, 403, 429, a malformed response, a refused redirect target | Rethrown. Under ISR, Next keeps serving the last good page when a revalidation fails; a failure on a first render is an uncached 500 |
| notFound() and redirects | Next control flow, always propagated (unstable_rethrow) |
Why rethrow: a rendered "unavailable" page is a normal 200 render, so ISR would cache it and replace the last good article for everyone for the revalidate window. onError: (error) => ReactElement is the opt-in for a custom UI anyway; its element IS cached like any render, and the error it receives can contain Cp1ClientError.message and requestId, so never render it (only its name is logged, never the token or message). The Unavailable component (en, fr, es; data-hyle-page="unavailable", role="status", no error text) is exported from ./react for onError or your own error.tsx. hyleMetadata follows the same split: a 4xx rejection gives {}, anything else is rethrown, so a transient outage never caches a noindex page.
Sitemap
The contract has no "list every page" endpoint, so the helper pages the list result at each section index (100 per page, next_cursor, at most 20 pages per index by default). Spread it into your sitemap:
import { hyleSitemap } from "@tdacorp/hyle-sdk/next";
export default async function sitemap() {
return [...staticEntries, ...(await hyleSitemap({ ...options, origin: "https://tdacorp.in", sections: ["docs"] }))];
}origin must be a bare https origin (anything else throws). URLs are percent-encoded and use localePrefix; the locale is options.locale when it is a string, and a function of the route params is never called (the sitemap is for the default locale). lastModified is published_at (omitted if it is not an ISO date): the list carries no updated_at. Only the default section and the sections you list are covered (a section's index resolves at basePath/<slug>), at most 2000 pages per index. onIncomplete decides what a hyle failure (or a listing cut off by the page limit) does: "partial" returns what was collected, "throw" throws so a build during an outage fails; the default throws only when nothing was collected. The core listPublicationPaths({ client, basePath, sections?, locale?, maxPages? }) never throws and returns { paths: { path, publishedAt }[], complete }, where complete: false means paths is partial.
Root catch-all
hylePage serves one mount you name (/learn). hyleRootPage instead hands hyle EVERY URL the host did not match, and asks hyle where it publishes (GET /cp1/read/mounts), so the host config no longer lists prefixes. It is the replacement for a site's notFound() catch-all.
// app/(site)/[...hyle]/page.tsx (a REQUIRED catch-all: [...hyle], not [[...hyle]])
import { hyleRootMetadata, hyleRootPage } from "@tdacorp/hyle-sdk/next";
const config = { baseUrl: process.env.HYLE_BASE_URL!, token: process.env.HYLE_TARGET_TOKEN!, workspaceId: process.env.HYLE_WORKSPACE_ID!,
locale: (p) => String(p.locale), localePrefix: (l) => (l === "en" ? "" : `/${l}`) };
export default hyleRootPage(config); // or hyleRootPage([configA, configB]) for several workspaces
export const generateMetadata = hyleRootMetadata(config);
// app/sitemap.ts: [...hostEntries, ...(await hyleRootSitemap(config, { origin: "https://tdacorp.in" }))]Options are those of hylePage without basePath (it comes from the mount) and resolver; baseUrl, token and workspaceId are required because the mounts are read through Next's fetch cache (tag hyle:1:ws:<id>, revalidate default 300). An array serves several workspaces from one site; the longest matching prefix wins across them and the first config wins a tie. All configs must use the same param (default hyle).
Why a REQUIRED catch-all: an optional catch-all at the app root ([[...hyle]]) also matches /, so it conflicts with the sibling page.tsx that every site has for its home page (verified: next build fails with "You cannot define a route with the same specificity as an optional catch-all route ("/" and "/[[...hyle]]")"). [...hyle] needs one or more segments, so the home page stays yours.
How it works, and why it does not interfere with the host
- Next matches specific routes before a catch-all.
/about,/api/x,/sitemap.xmland every other page or route handler of the host never reachhyleRootPage; it only receives URLs nothing else matched. The host's matcher keeps_next,apiand files with an extension out of any middleware or proxy exactly as before. - For such a URL the page decodes the path once and refuses anything odd (encoded slash,
.or.., empty segment, bad encoding, control characters, non-NFC) with a 404. It then compares it with the mounts: segment-boundary and case-sensitive (/learnmatches/learnand/learn/x, never/learnxor/Learn; a root mount/matches the rest), longest prefix wins. - No mount matches, or the path is reserved:
notFound()at once. A workspace whosereservedlist covers the path takes no part in the choice, so another workspace's mount (for example a root mount) can still serve it; the path is a 404 only when no remaining mount matches. hyle gets ZERO resolve calls, so junk URLs and bot probes cost one cached mounts lookup at most (the mounts are fetched once per workspace per revalidate window, not per URL). - Otherwise the request is served by
hylePagewithbasePath= the matched prefix: same locale handling, redirects, list and article pages, same failure rules. - The app always wins a collision: a host page at a path hyle also publishes is served, hyle is not asked, and the hyle page is simply never shown. The three guards against that silent loss are hyle's Reserved paths (hyle refuses to publish onto them), the build-time checker below, and this precedence (so a collision can never break a host page).
Failure modes
| Situation | Result |
|---|---|
| Mounts answer 401 or 403 (revoked token, deprovisioned workspace) | the last good copy is dropped and the error is rethrown (Next keeps the last good ISR page; a first render is an uncached 500) |
| Mounts answer with a 4xx, or the read surface is switched off (kind: "disabled") | nothing is served: 404. The last good copy is dropped, so the kill switch is not undone by a later outage |
| Mounts fail transiently (network, timeout, 5xx, 429, malformed body) and a copy read within the last 15 minutes exists | that copy decides: unmatched URLs stay a plain 404, mounted pages go on to hylePage (which keeps its own last-good-page rule) |
| Mounts fail transiently and there is no usable copy (a cold server, or a copy older than MOUNTS_MAX_STALE_MS, 15 minutes) | the error is rethrown: Next keeps the last good ISR page, a first render is an uncached 500. It is not guessed to be a 404, because the URL might be a mounted page |
| Root mount (/) and no usable copy | every unmatched URL errors until hyle is back (the root mount could own any of them) |
| hyle is down | the host's own pages are unaffected: they never call hyle |
The last good copy is in process memory, keyed by hyle origin plus workspace id (never the token). Next's fetch cache serves a stale mounts response while a refresh fails, so in practice the in-memory copy is a second layer.
Adopting it on a Next 16 + next-intl site
- Add the reserved-paths sync to your production build (see "Syncing reserved paths" below); nothing is pasted into hyle by hand.
--print-reservedstill prints what would be sent. - Replace
app/[locale]/[...rest]/page.tsx(thenotFound()catch-all) withapp/[locale]/(site)/[...hyle]/page.tsx, put it under the layout group that carries the site chrome, and keep yournot-found.tsxin that group: a 404 from the catch-all then renders inside the chrome. Passlocale: (p) => String(p.locale)andlocalePrefixas forhylePage. - Keep
dynamicParamstrue (see Host requirements). Keep the proxy matcher as is. - Add
hyleRootSitemaptoapp/sitemap.ts, run the checker (below), deploy.
Collision checker
hyle-sdk-check-routes (bin of this package) reads .next/routes-manifest.json and .next/server/app-paths-manifest.json of a finished next build, and hyle's mounts plus the published pages of each mount (read-only; HYLE_TARGET_BASE_URL or --base-url, and HYLE_TARGET_TOKEN from the environment only). It reports (a) hyle pages shadowed by a host static route, (b) host dynamic routes that match hyle URLs, with a verdict from route specificity ([x] before [...x] before [[...x]]; the hyle catch-all itself is fine), (c) mounts whose prefix is a host static route. Exit 1 on (a) or (c), 0 otherwise, 2 when it cannot run (no build, no credentials, hyle unreachable or the listing incomplete). --json prints the report; --print-reserved prints the host's routes in hyle's Reserved paths format (see "Syncing reserved paths" for the derivation). --locales, --default-locale and --locale-prefix as-needed|always|never expand a leading [locale] segment (next-intl). --sections a,b lists extra sections; --hyle-param names the catch-all param. A root mount over the host's home page is a note, not a failure. In CI, run it after next build as a required step. The same functions are exported from @tdacorp/hyle-sdk (hostRoutesFromManifests, checkRoutes, reservedFor, formatReport).
Syncing reserved paths
Reserved paths are the URLs your app owns, which hyle refuses to publish onto. They are derived from your build and sent to hyle, so nobody maintains them in hyle's UI:
hyle-sdk-check-routes --sync --no-check --locales en,fr,es --default-locale en --locale-prefix as-neededRun it as the last step of the PRODUCTION build (a postbuild script, or the build command after next build). It needs, from the environment only:
| Variable | What |
|---|---|
| HYLE_TARGET_BASE_URL, HYLE_TARGET_TOKEN | already set for the site: finds the workspace through mounts (the read token only reads) |
| HYLE_CONTROL_TOKEN | NEW: a control credential (hyle_tc_...) whose principal holds hyle.workspace.manage on that workspace. The read token cannot write and is refused before any request. Mint it in hyle under Workspace > Agents (a name like site-sync); while hyle authorizes through identity, also link that agent's principal to an identity service principal bound to hyle.workspace.manage only (scripts/link-principal-identity.mjs, as the Agents screen says), otherwise the write answers 404. Store it as a secret in the Production scope of the host only |
| HYLE_HOST_ID | optional name for the audit trail; defaults to VERCEL_PROJECT_PRODUCTION_URL, or pass --host |
What is sent: PUT /cp1/control/workspace/host-reserved-paths with { paths, host, build_id } (the build id is .next/BUILD_ID). The list: static routes exact; a segment with two or more routes becomes /x/*; a dynamic route becomes the wildcard on its static prefix (/shop/[id] is /shop/*, /docs/[...slug] is /docs/*); skipped are your hyle catch-all (--hyle-param), a route with no static prefix, anything on a mount's segment (the checker reports those as (b)), per-build hashed metadata routes (/opengraph-image-1ycygp), and / when hyle is mounted at the root. At most 512 entries. hyle stores it as a separate set (the workspace's own Reserved paths are never touched), enforces the union when publishing, renaming and re-prefixing, and shows it read-only in the workspace settings with the host, build and time.
- Never fails your deploy. A failed sync (hyle down, wrong token, not granted) prints a
sync:warning and exits 0. Add--strictto exit 3 instead. Without--no-checkthe collision check also runs and keeps its own exit codes (1 collision, 2 cannot run). - Production only. When
VERCEL_ENVis set and is notproductionthe sync is skipped, so a preview cannot replace production's list (--allow-non-productionoverrides). Elsewhere, only run it in production builds. - Idempotent. The same list changes nothing in hyle (
unchanged). An empty list is refused (--allow-emptyoverrides) so a broken manifest read cannot erase the reservations. - Conflicts. If a path you now own already has a published hyle page, hyle unpublishes nothing: the result lists those URLs (CLI output and the settings page). Your own route wins in Next, so the hyle page is simply not shown; move or unpublish it in hyle.
--jsonadds asyncobject. The programmatic form issyncHostReserved({ baseUrl, token, workspaceId, paths, host, buildId }).
Limits
- A junk URL still renders a 404 page in your site chrome; whether and how long Next keeps that render is Next's own ISR behaviour (not measured here). It never reaches hyle either way.
- The checker sees the published default-locale pages of each mount (at most 10,000 per mount) and host routes known to the build; routes that only a proxy rewrite creates, or translated pathnames, are invisible to it, so list those in the workspace's own Reserved paths by hand (that list is separate from the synced one and is never overwritten by a sync).
- Matching uses the mounts of the last successful read: a new mount is served after the mounts revalidate window (default 300 s) or a workspace-tag purge.
- A reserved or mounted URL can only be reached if no host route matches it first; that is the intended precedence.
Building blocks
fetchResolver(options) is the default fetch-cache resolver, and workspaceTags(workspaceId, sections?) builds its tags (hyle:1:ws:<id>, hyle:1:sec:<ws>:<section>). Wrap fetchResolver to adjust a result (pass the wrapper as resolver) instead of copying it.
Host requirements
Checklist for a site that mounts hyle at the root (details in the sections named):
- A REQUIRED catch-all
app/(site)/[...hyle]/page.tsx(not[[...hyle]]), see "Root catch-all". A single mount that you name (hylePageat/learn) needs only its own route. app/api/hyle/assets/[file]/route.tsexportingGETandHEADfromcreateAssetRoute, if content has images, video or audio, see "Media".- hyle's reserved paths must cover
/api/hyle/*and your other routes: runhyle-sdk-check-routes --syncafternext build(needsHYLE_CONTROL_TOKEN), see "Syncing reserved paths". - No parent layout or page above the mount may export
dynamicParams = false(below).
Bundler only: @tdacorp/hyle-sdk/next and /next/use-cache import next/navigation and next/cache the way a Next app does, so plain Node ESM cannot import them. Real next build runs of the packed package passed with Next 16.2.7, 16.3.4 and 16.4.2 (Turbopack, the default).
No layout or page above the mount may export dynamicParams = false. hyle content (an article published after your last build) is by definition not known at build time, and Next inherits a parent's false for every child route: the route is compiled with "fallback": false, so each path not generated at build time is a hard 404 that falls through to any [locale]/[...rest] catch-all (observed: x-matched-path: /[locale]/[...rest], content looks missing). Setting dynamicParams = true on the page does not override a parent's false.
Verify after next build: .next/prerender-manifest.json must not show "fallback": false for the route that mounts hylePage, and a request for a published article that did not exist at build time must return 200.
Locale-prefixed hosts: you may keep generateStaticParams for the locales, but validate the locale in the layout (call notFound() for an unknown one) and set dynamicParams = true there, so unknown locales are still rejected.
Media
hyle stores an image, video or audio reference as /assets/<uuid>.<ext>, a path that exists only on hyle's own (authenticated) origin. On a host site it would 404, so the SDK rewrites it while rendering (HastContent, so every page component, and the format: "html" escape hatch) to /api/hyle/assets/<uuid>.<ext> on the host's own origin. That path is answered by the one route file in the quickstart, app/api/hyle/assets/[file]/route.ts; nothing else is needed per site, and a strict CSP such as img-src 'self' data: (and media-src left at default-src 'self') works unchanged. The Image slot receives the rewritten src, so a next/image slot also works; the default is a plain <img loading="lazy">.
How it works: createAssetRoute asks hyle for exactly that file (GET /cp1/read/assets/<file>, ADR 0015) with the target credential and streams the bytes back. hyle answers only for an asset that a currently published, non-trashed page of this workspace references, so a draft, unpublished or other workspace's asset is a 404 on your site too. Responses carry Cache-Control: public, max-age=3600, s-maxage=86400, stale-while-revalidate=86400, hyle's strong ETag (revalidation is a 304) and Accept-Ranges; Range is forwarded, so video and audio seek. After an unpublish a cached copy can still be served for up to the cache window (about a day on a CDN).
Safety: the only input is the file segment and it must match <lowercase uuid>[.png|jpg|gif|webp|svg|pdf|mp4|webm|mp3|ogg] before anything is sent, so the route is not an open proxy. Only Range, If-Range and If-None-Match are forwarded (never the visitor's cookies or Authorization); the token goes to hyle only. Response headers are built by the SDK, the content type must be on the asset allowlist (an HTML or script answer is refused), the body is capped at 16 MiB (maxBytes) and the upstream call times out after 30 s (timeoutMs). Failures are empty responses: 404 for an unknown or unpublished asset, a bad name, a missing configuration or HYLE_CONTENT_ENABLED=0, 502 for any upstream error, so hyle's state and your token never leak. SVG is served as image/svg+xml with Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; sandbox and nosniff (an <img> never runs SVG script; navigating to the URL runs none either).
The route sits under /api/hyle/*, which hyle's Reserved paths must cover (hyle-sdk-check-routes --print-reserved always lists /api/hyle/*, and so does --sync), and the checker prints a note when the build has no /api/hyle/assets/... route. If your proxy or middleware matcher intercepts /api, exclude /api/hyle/assets. Nothing renders /assets/... for you outside the SDK's components: a custom renderer of the hast tree should map src the same way (/assets/<id>.<ext> to /api/hyle/assets/<id>.<ext>).
Comments and likes
Readers can comment and like under an article (hyle ADR 0017, docs/cp1-spec.md section 3.11). It is off unless you turn it on in three places, and nothing on a cached page changes when someone likes: the section is fetched in the browser, uncached, after the article has rendered.
- hyle: switch engagement on for the workspace and register the site's public key (below).
- A key pair.
npx hyle-sdk-engage-keygen --out .env.engagemakes an Ed25519 pair: the private key goes to a new file (mode 0600, never overwritten) asHYLE_ENGAGE_SIGNING_KEY=..., and the PUBLIC key is printed only after that write succeeded, with a warning if git does not ignore the file.--stdoutis the only mode that prints the private key; with no arguments the tool prints usage and nothing else. Register the public key in hyle. hyle never sees the private key. To rotate, register the new public key; the old one stays valid for 24 hours. - The route file
app/api/hyle/engage/[...path]/route.ts:
import { createEngagementRoute } from "@tdacorp/hyle-sdk/next";
export const { GET, POST, PATCH, PUT, DELETE } = createEngagementRoute({
baseUrl: process.env.HYLE_TARGET_BASE_URL,
token: process.env.HYLE_TARGET_TOKEN,
workspaceId: process.env.HYLE_WORKSPACE_ID, // the target credential's workspace; hyle checks the assertion's claim against it
signingKey: process.env.HYLE_ENGAGE_SIGNING_KEY,
// Your own session check. Return { sub, name } for a signed-in reader, null for none. `sub` is any stable id you own (up to 255
// characters), `name` is what other readers see (up to 200). The visitor's cookies are read by YOU here and never forwarded to hyle.
getReader: async (req) => {
const user = await currentUser(req); // your auth
return user ? { sub: user.id, name: user.displayName } : null;
},
});Rate-limit this route yourself. hyle limits per reader and per site, but every anonymous read of /api/hyle/engage/comments and every session check reaches your server first. The route shares ANONYMOUS listings between visitors for 3 seconds (cacheTtlMs, 0 turns it off); a signed-in reader's listing is shared only with that same reader (never across readers), a fallback answer is never kept, bodies over 256 KB are never kept, and the whole share is capped at 8 MB (least recently used out first); a write on that server makes everything stale at once, other servers catch up within the window, and onRequest(req, { action }) runs before any work so you can answer with your own 429 (action is session, comments.list, comments.create, comments.edit, comments.delete, reactions.add or reactions.remove); put a limit on /api/hyle/engage in your middleware or CDN as well. getReader runs once per request and its answer is never kept for the next one, so keep it cheap. The name you return is cleaned as hyle cleans it (control, bidi and invisible characters stripped, spaces collapsed, 80 characters) and a reader whose assertion would pass hyle's 2 KiB cap is treated as signed out.
Then pass engagement to the page: hylePage({ ..., engagement: { signInHref: "/sign-in" } }), or <ArticlePage engagement={{ signInHref: "/sign-in" }} />. engagement.endpoint defaults to /api/hyle/engage. layout: "docs" has no comments.
What the route does, per request: it matches (method, path) against a fixed allowlist (session, comments, comments/<uuid>, reactions), rebuilds the body with only the known keys (a comment is 1 to 4000 characters, a path is a canonical site path), and calls hyle with the target credential. An anonymous read carries the bearer only. A signed-in reader's read also carries a separate Ed25519 read assertion (scope: "read", 30 s, not single use) so hyle can add that reader's own state; a write carries a fresh single-use assertion (30 s) of the reader getReader returned; a missing or invalid reader is a bare 401 before hyle is called. The browser's cookies and Authorization are never forwarded. Writes must be same-origin (Sec-Fetch-Site: same-origin when sent, and an Origin equal to the request's host, with the scheme compared too when the proxy sends X-Forwarded-Proto), carry the header x-hyle-engage: 1 and application/json (at most 20 KiB), otherwise 403/415/413. The host is taken from X-Forwarded-Host, else Host: behind a proxy that rewrites Host or terminates TLS without forwarding these headers, pass allowedOrigins: ["https://www.example.com"], which replaces the derivation with an exact list (scheme, host and port). Every response is Cache-Control: no-store. Failures are mapped to a fixed set of codes with no text from hyle: hyle 404 stays 404 (a disabled workspace, a banned reader and a hidden comment look the same), 429 keeps a clamped Retry-After, a 409 is an edit after the 10 minute window, and everything else, including a 401 from hyle (a wrong key or credential, which a visitor must not learn), is a 502. The kill switch is HYLE_ENGAGE_ENABLED (and HYLE_CONTENT_ENABLED): unset, empty, 1 or true leave the route on, and ANY other non-empty value (0, false, off, no, a typo) answers 404 everywhere; a missing or malformed configuration also answers 404 and never throws at build time. Your proxy or middleware matcher must let /api/hyle/engage through, and the route sits under /api/hyle/*, which hyle's Reserved paths already cover (see "Collision checker": --print-reserved and --sync always list it).
The component (Comments, a client component with no dependency) renders the states of the design kit: signed out, composer, threads to depth 4 (no Reply at depth 4), likes with a pressed state and an optimistic count, a reader's own pending comment ("Awaiting approval", visible only to them), edited marks, tombstones for removed and deleted comments, empty, loading, load error with Try again, post error that keeps the text, rate limited (the box is disabled, the button is aria-disabled and stays focusable), banned, and Load more (20 threads at a time). A write that answers 404 is not taken for a ban: the listing and the session are reloaded and a plain error shows; only a second 404 for a still valid session shows the banned message. Comment bodies are the sanitized hast hyle renders; the client sanitizes again with a narrower allowlist (links, emphasis, code, quotes, lists) and builds React elements, never HTML strings. Ctrl or Cmd+Enter posts, Escape closes a reply and returns focus to its button. Styling uses the existing --hyle-* variables plus six optional ones: --hyle-comments-indent, --hyle-comments-gap, --hyle-comments-avatar-size, --hyle-comments-rule, --hyle-comments-held and --hyle-comments-like-on. Strings are in en, fr and es (fr and es are unreviewed drafts).
The listing is viewer-aware (hyle cp1 spec section 3.11): for a signed-in reader the route sends a separate read assertion (scope: "read", 30 s, not single use) with the GET, and hyle adds the reader's own state, so a reload restores their pending comment (mine_held), their pressed likes (liked_by_me) and Edit (only while can_edit_until is in the future; Delete stays available on the reader's own comments, as hyle allows it at any time). Edit after a reload starts from Markdown rebuilt from the rendered comment (hyle lists rendered comments, not their source), which can differ from the original in whitespace and escaping. Viewer reads are never shared between readers; a 401 on the read assertion is re-signed once and retried. If the viewer read cannot be served for the reader's own sake (a 401 after one re-sign, which means a wrong or rotated key; a 404, which means no key is registered at hyle yet; or a 429 that hyle marks Hyle-Throttle: viewer, the reader's own view bucket) the route answers with the public listing, so a signed-in reader never loses the section. Any other 429, the site-wide read bucket, is passed on with its Retry-After and not retried. A reader hyle has never seen, a banned reader and an anonymous visitor all get the public listing (no viewer members). Tombstones say whether a moderator removed the comment or its author deleted it. Known limits: there is no cursor, so a page holds at most 500 comments (and at most 100 000 characters of comment source are rendered per response); an edit can put a visible comment back in the moderation queue, in which case it moves to the reader's pending list.
Embeds
Show hyle content inside a page you own, such as a "latest notes" strip on a home page or one article on a product page, without mounting the whole site. They are async server components and take the same options object as hylePage (baseUrl, token, workspaceId, basePath, localePrefix, components, slots, onError, or a cachedResolver).
import { Suspense } from "react";
import { HyleArticle, HyleComments, HyleCommentsFallback, HyleList } from "@tdacorp/hyle-sdk/next";
// The 3 newest articles as cards, linking to the mount.
<HyleList options={options} limit={3} layout="grid" title="Latest notes" headingLevel={2} moreHref="/learn" />
// One article in place (path = the article's canonical_path, with the workspace prefix and no locale prefix).
<HyleArticle options={options} path="/learn/computing-reading-time" headingLevel={2} />
// Crawlable comments, streamed. The component awaits its fetch, so Suspense is how a page keeps streaming.
<Suspense fallback={<HyleCommentsFallback />}>
<HyleComments options={{ baseUrl: process.env.HYLE_TARGET_BASE_URL!, token: process.env.HYLE_TARGET_TOKEN! }} path="/learn/computing-reading-time" />
</Suspense>HyleList:section(a slug other than the defaultposts, resolved underbasePath),limit(default 5, at most 100),layout("list"or"grid", default list),title,headingLevel(2, 3 or 4, default 2),moreHref(a "View all" link to a URL you choose, usually the mount) andlocale(needed whenoptions.localeis a function of route params, which a component cannot evaluate). It is a flat list, newest first, with no month groups and no pagination. The slotslist.itemandlist.emptyofoptions.slotsapply.HyleArticle:path,headingLevel(default 2),locale. Title, date, description, the body, and a visible "Read at source" link. No table of contents, pager, languages bar, comments or JSON-LD. A path that is not an article (a redirect included) renders nothing.HyleComments:options: { baseUrl, token, locale? },path(the article's canonical path),revalidate(seconds, default 60) andinteractive. The listing is public and cached forrevalidateseconds, not purged by a webhook. It renders the thread as server HTML (ol > li > article, names, dates, sanitized bodies, linksrel="nofollow ugc", removed comments as tombstones) and nothing at all when there are no comments or hyle answers 404 (engagement off, unknown article) or fails. Withinteractive(true, or{ endpoint?, signInHref? }) theCommentsisland is rendered INSTEAD, seeded from the same listing: the thread is still in the server HTML, there is no skeleton flash, readers can comment and like (it needscreateEngagementRoute, as in "Comments and likes"), and it refetches silently on mount. The listing is serialized once into the page for the island (up to 500 comments), so leaveinteractiveoff where you only need to show comments.
SEO rules the embeds follow, and one you must:
- Links back: every item and the "Read at source" link is a plain
<a href>to the canonical page on the mount, through yourlocalePrefix.HyleListshows links and snippets only. - Nothing in the document head: no
<title>,<link>,<meta>or JSON-LD. Your page keeps its own metadata. - Canonical: a page that embeds a full article with
HyleArticlemust set its own canonical URL to the article's URL on the mount (for examplealternates.canonicalingenerateMetadata), or the two pages compete as duplicates. A list or a comments embed needs nothing. The embeds build no absolute URLs; those belong to the mount (see its documentation). - Headings: the default level is 2, and the article body's headings are h2 and below (a body
h1is demoted), so under your page'sh1and a section heading of your own, setheadingLevelto 3. - Failure: an embed never calls
notFound(), never redirects and never throws on a hyle outage. A missing page, a request hyle rejects and an empty section render nothing (or thelist.emptyslot). Any other failure is logged by name and rendersonError(error)if you gave one, else nothing. A missingworkspaceId, a badbaseUrlor an emptytokenis a configuration error and throws. - The token is used only in the server-side request.
HyleCommentssends the target credential and nothing else (no reader assertion, no cookie, no header of the visitor), so every visitor shares one cached listing. TheCommentsisland receives the listing, the path, your own/api/hyle/engageroute and the chrome strings: never the token or hyle's URL.
Styling: import @tdacorp/hyle-sdk/styles.css. An embed is a .hyle.hyle-embed block with no page padding or maximum width, so the host's layout sets its width; data-layout="grid" lays the items out as cards (260px minimum columns). The pure markup is also exported as HyleListView and HyleArticleView from @tdacorp/hyle-sdk/react for hosts that fetch for themselves.
Mermaid diagrams
Zero setup. A Mermaid fence arrives from hyle as a sandboxed <iframe srcdoc> that loads a script from cdnjs, and a srcdoc frame inherits the host page's CSP, so a host with script-src 'self' would show a blank box. The SDK therefore reads the diagram text out of that document (only after it matched hyle's exact template) and renders it in your page with the Mermaid library it bundles as a dependency (mermaid ~11.9.0, MIT). No cdnjs, no extra package, no config.
- What loads when: the server renders a no-JavaScript fallback, a
<figure class="hyle-mermaid">holding the source as alanguage-mermaidcode block. On a page with a diagram, the browser then importsmermaid(a dynamic import, so your bundler emits it as lazy chunks served from your own origin) and swaps in the SVG. Pages without a diagram never load it: the client component is not even in their JavaScript. In the e2e app (Next 16.2.7, Turbopack) the lazy chunks total about 2.8 MB raw, 0.8 MB gzipped across every diagram type; a page loads only the ones its diagram type needs.mermaiditself is 64 MB on disk innode_modules(it is not shipped to browsers). - Rendering: a diagram renders when it is within 200 px of the viewport, one at a time, each limited to 10 s (a slower one falls back to the code block and the note). Per-diagram configuration (
%%{init}%%directives and frontmatter) is stripped, see Security model. A re-render (theme change) keeps the previous SVG on screen until the new one is ready, and keeps it if the new one fails. - Themes: dark or default is chosen from the figure's computed background (falling back to
prefers-color-scheme), and the diagram re-renders whendata-act,data-themeorclasschange on<html>or the OS scheme changes. - Fallback: if the source cannot be extracted (over 20,000 characters, or not the renderer's template) the old sandboxed frame is used, which needs cdnjs in your CSP. If Mermaid cannot render the source (a syntax error), the code block stays and a short "diagram could not be rendered" note appears.
- Host CSP:
script-src 'self'is enough (plus'unsafe-inline'or nonces for Next's own inline scripts, as for any Next app);style-src 'unsafe-inline'is required because Mermaid's SVG carries inline styles;img-srcdecides whether images that a diagram label references load. Checked underdefault-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:in headless Chromium on Next 16.2.7, 16.3.4 and 16.4.2 (node scripts/e2e-next-build.mjs --browser). - Override:
components.Mermaidstill replaces all of this. It receives{ srcDoc, source, title };sourceis the diagram text, orundefinedwhen it could not be extracted.MermaidDiagram({ source, title, errorText }) is exported from./reactif you want to wrap it. It is the SDK's only client component and takes nothing but those strings: no token or client ever crosses the boundary.
Diagram viewer
Every Mermaid diagram is a button. Click it, press Enter or Space on it, or tap it, and it opens in a dialog you can zoom, pan, trace and search. Nothing to install or configure: it ships in the package, has no extra dependency, uses no host UI library, and works under the same strict CSP as the inline diagram.
- Lazy: the viewer is its own chunk, fetched by the first click and never before. A page whose diagrams are never opened pays only for the trigger: about 2.2 KB raw (1 KB gzipped) more than 0.3.0, the button markup in
MermaidDiagramand the English strings; the viewer itself is about 48 KB raw (14 KB gzipped) fetched on the first click. It shows the SVG the page already rendered (ids and#idCSS scoping rewritten to a unique id), so Mermaid does not lay the diagram out a second time; after a theme change the dialog swaps in the re-rendered SVG and keeps your zoom and your pinned node. - Dialog: a native
<dialog>opened withshowModal(): the page behind is inert and focus cannot leave,Escor a click on the backdrop closes it, focus returns to the trigger, page scroll is locked (a class on<html>), the open animation and the flowing dashes switch off underprefers-reduced-motion, and on a phone it fills the screen with 44 px touch targets. - Zoom and pan: the wheel (or a trackpad pinch) zooms around the cursor from 0.2x to 10x; drag pans with pointer events (mouse, touch, pen) and glides on release; two fingers pinch and pan; double-click or double-tap zooms 2x (on a node: to that node); a readout shows the percentage; it opens fitted to the dialog.
- Flowchart, state, class and ER diagrams (anything Mermaid draws as
g.nodeelements): hover or keyboard-focus a node and it, its direct neighbours and the edges between them stay lit while everything else dims. Click or press Enter and the node is pinned and traced: every upstream path in one tone, every downstream path in another (colours in the legend), with dashes flowing along the traced edges. Click the background to clear.For a double-click zooms to the node and its neighbours with an eased transition. A minimap with a draggable viewport rectangle appears from 12 nodes. The graph is read from the rendered SVG (node ids, edge ids, or where each edge path starts and ends), never from the Mermaid source. - Find:
/or Ctrl+F opens a find box; matching nodes light up and the rest dim,EnterandShift+Enterstep through the matches and bring each into view. Diagrams without nodes search every SVG text element. - Export:
MMDcopies the Mermaid source,SVGandPNGdownload (PNG at 1x, 2x or 4x, scaled down past canvas limits),IMGcopies the PNG to the clipboard where the browser allows it. Clipboard calls run only inside the click that asked for them; downloads areblob:URLs created on click and revoked afterwards; the PNG is drawn from adata:SVG image, so a host CSP must allowimg-src data:or the button reports "Not available here". - Fullscreen: a button when
document.fullscreenEnabled(the Fullscreen API refuses<dialog>elements, so it fullscreens the content wrapper inside the dialog). - Links: a node with a Mermaid
click A "https://..."link keeps working: a click on it follows the link (inline and in the dialog), a drag that starts on it only pans, and Enter or the trace keys still pin it. Large diagrams (over 150 nodes) skip the fade between highlight states, and a trace of more than 120 edges is drawn without the flowing dashes, to keep it instant (a 500-node trace paints in about 40 ms). - Other diagram types: sequence, gantt, pie, journey, mindmap and the rest have no node and edge structure to trace, so they get everything except tracing and keyboard traversal: zoom, pan, fit, fullscreen, find by text and export. A node whose edges cannot be matched is simply left out of the trace; extraction never throws (an unreadable SVG is a plain pan and zoom view).
| Key | Does |
|---|---|
| + = / - | Zoom in / out around the centre |
| 0 or Home / 1 | Fit to the dialog / 100% |
| Arrow keys | With a node focused: move to the neighbouring node in that direction (a direct neighbour first, then the nearest node that way). Otherwise pan (Shift: 4x) |
| Enter or Space on a node | Pin or unpin its trace |
| F | Zoom to the focused, pinned or hovered node and its neighbours |
| / or Ctrl+F | Find; Enter next match, Shift+Enter previous, Esc closes the find box only |
| Esc | Close the dialog |
Tab moves through the toolbar, then to the diagram (one tab stop; arrow keys move between nodes). Each node is a role="button" named from its text and outgoing edges ("Ok?, 2 outgoing: yes, no") with aria-pressed for the pin, a status line announces the trace summary, and the dialog is named "Diagram viewer: ".
CSS variables, all optional, set anywhere above the mount: --hyle-viewer-bg (default --hyle-surface), --hyle-viewer-accent (selection, matches and the minimap rectangle; default --hyle-accent), --hyle-viewer-up and --hyle-viewer-down (the two trace tones; default --hyle-warn and --hyle-ok), --hyle-viewer-dim (opacity of dimmed parts, default .16), --hyle-viewer-backdrop. The dialog also reads the usual --hyle-border, --hyle-text, --hyle-text-muted, --hyle-radius-lg and --hyle-shadow, each with a neutral fallback. The styles live in styles.css; there is no inline style attribute and nothing else to load.
Limits: PNG export of a diagram with HTML labels depends on the browser drawing SVG foreignObject into a canvas (Chromium does); the minimap and tracing are for node diagrams only; components.Mermaid replaces the viewer together with the diagram; the CommonJS build (dist/mermaid-diagram.cjs) inlines the viewer instead of splitting it, which only matters to a CJS bundler.
Styling
Import @tdacorp/hyle-sdk/styles.css. It is the design kit's stylesheet: every value comes from a public --hyle-* custom property with a SIGNAL fallback (--sig-* from @tdacorp/signal-tokens, so dark and light acts flip with no extra code). Set variables on :root, body or any wrapper above the mount; the SDK never declares them:
:root { --hyle-accent: #ff8a3d; --hyle-radius: 12px; --hyle-font-heading: "Fraunces", serif; }The 52 variables are listed in the token block at the top of styles.css. Layout reacts to the width of the mount (container queries), not the window. Code uses highlight.js token classes (hljs-*, mapped to --hyle-syn-*). Math arrives as KaTeX HTML from the server: load katex/dist/katex.min.css in the host (it brings the fonts and resolves their URLs relative to itself). It is not bundled here because the fonts are about 1 MB of binary assets that would tie this package's version to KaTeX's and double-load in hosts that already use KaTeX.
Customization
components replaces how a part is drawn, slots fills named places. Both are props of the pages and options of hylePage.
- Component slots:
Link(every internal link, breadcrumb and in-page link: pass your router's Link),Image,CodeBlock(receivescode,language, and the defaultchildren; the stock block has no copy button because that needs client JavaScript),Mermaid(receives hyle's sandboxedsrcDocand the extractedsource; the default renders the diagram in the page, see "Mermaid diagrams"),Callout(GFM alerts such as> [!NOTE]). The raw element overridesa,img,code,pre,table,blockquoteandh1toh6also work. - Visual slots:
article.before,article.meta,article.aside,article.after,list.header,list.item(a component that receives the item),list.empty,docs.sidebar-header,docs.version,docs.sidebar-footer,docs.after,notfound.body,notfound.actions,redirecting.body. An unfilled slot renders nothing. - Layout:
layout: "docs"renders articles asDocsPage, with no host code. The sidebar is the whole section (hyle sends it when the request carriesnav=tree, which the layout adds; hidden pages are excluded and their children promoted, one locale): collapsible groups (<details>, so it works without JavaScript), the path to the current page open and the page markedaria-current="page". Also a breadcrumb, previous and next links, an on-page contents list with scroll-spy (aria-current="location", an IntersectionObserver, no dependency, no inline script), "Last updated" from the document'supdated_at, and on a narrow mount (container width under 760px) a Menu button that opens the sidebar as a drawer (the nativepopoverattribute: Escape and an outside click close it, focus returns to the button). A section above 2,000 pages in one locale is sent without the tree, and so is an older hyle: the sidebar then shows only the path to the page and its children. Restyle with the--hyle-*variables (--hyle-nav-width,--hyle-toc-width,--hyle-sticky-topfor a sticky host header) and the slots above. A version badge is only what you put indocs.version: hyle has no docs-version data.
Chrome strings exist for en, fr and es (fr and es are unreviewed drafts). The page title is the only h1: a body h1 becomes h2, and a leading body h1 that repeats the title is dropped.
Security model
- The token is sent only as an
Authorizationheader from the server. It is never placed in a URL, logged or put in an error message. Keep it out of client components. The client requires an httpsbaseUrl(http only for localhost, or withallowInsecureHttp), refuses redirects, aborts aftertimeoutMs(default 10 s) and refuses responses overmaxResponseBytes(default 5 MiB). - The path is sent as a query parameter (encoded once); catch-all segments are decoded and refused if they contain
/,\, control characters,.or... - The
hasttree is rendered through an allowlist (sanitizeHast) even though hyle sanitizes server-side: only known tags and properties with accepted value shapes survive; noon*attributes,styleonly inside KaTeX output and only for the properties KaTeX emits with small values (noposition:absolute,z-index,leftor large sizes), ids and in-page fragments scoped with the server'suser-content-prefix (so heading ids, footnote links and TOC links still resolve),hrefandsrclimited to site-relative paths andhttp(s)(andmailtofor links), unknown tags are unwrapped,scriptandstyleare dropped, and aniframeis kept only withsandbox="allow-scripts"and asrcdocthat is exactly the document hyle's renderer builds for a Mermaid fence (pinned script URL, at most 64 KiB); the SDK addsreferrerpolicy="no-referrer"and acspthat allows only that script (checked in headless Chromium: the diagram still renders). A tree over 20,000 nodes or 512 KiB of text fails closed withHastLimitErrorand a safe notice.dangerouslySetInnerHTMLis used in exactly three places: the Mermaid SVG sink (below), the same SVG with rewritten ids in the diagram viewer's stage (below), andTrustedServerHtml, the escape hatch forcontent.format === "html": it renders HTML as is, so pass it only HTML that came straight from hyle's resolve endpoint, never user input. - Mermaid renders into your page, so: the source comes only from hyle content, passes the exact-template check and a 20,000 character cap, and is rendered with
securityLevel: "strict"(Mermaid's own DOMPurify pass, no raw HTML labels) andmaxTextSizeset to the cap. The SDK strips every%%{ ... }%%directive (multi-line, unterminated andinit,initialize,config,wrapforms) and a leading YAML frontmatter block from the source before rendering, and keeps plain%% commentlines. Reason: Mermaid'sthemeCSS(set by a directive or by frontmatterconfig:) is injected into the page as CSS and its sanitizer only checks brace balance, so a diagram could make the page fetch a URL or overlay the host UI; verified against Mermaid 11.9.0 with a loopback canary server, which received requests when the stripping was disabled and none with it. Consequence: authors cannot configure a diagram per diagram; a diagram that carries an init directive renders with the SDK's defaults. As a second guard.hyle-mermaid-svgusescontain: paintand the SVG isposition: static !important, so diagram CSS cannot place anything over the host page. FlowchartstyleandclassDeflines cannot carryurl(...)(they fail to parse). What remains: Mermaid keeps an<img>inside a label (event handlers are stripped), so an author can make the page request an image, and your CSPimg-srcgoverns that exactly as for Markdown images (the e2e proves a strictimg-src 'self' data:stops it); aclick A "https://..."line makes a clickable link, a phishing-link capability inside author-controlled content, so treat hyle authors as trusted to link. Nothing is executed: the SDK ignores Mermaid'sbindFunctions, and the hostileclick ... call,<img onerror>andsecurityLevel: "loose"payloads did nothing in the browser check. The host CSP needsstyle-src 'unsafe-inline'. - The diagram viewer adds no network request, no
eval, no storage and no new way to write HTML: its stage receives the very string the inline diagram holds (Mermaid's strict-mode DOMPurify output) after one rewrite of the SDK-generated render id (hyle-mermaid-N) to another SDK-generated token (hyle-viewer-N), applied only inside tags and<style>bodies and only for[\w-]+tokens, so it cannot add a quote, a tag or a script; the node and edge decoration only sets classes,tabindex,roleandaria-*on elements that already exist. Export buildsblob:anddata:URLs from that same string on click, and the clipboard is only touched inside the click that asked for it. A unit test pins these properties by reading the viewer's source (nofetch,eval,innerHTML =, storage ordocument.write, exactly onedangerouslySetInnerHTML). - The comments client talks only to the host's own
createEngagementRoute(same origin; no credential, no hyle URL, no storage, no cookie access in the browser bundle, which a test pins). The route is an allowlist, not a proxy, and writes need same-origin plus a custom header (see "Comments and likes"). The private signing key stays on the server and is never logged; a malformed key is refused with a message that does not contain it. - Webhooks are verified with HMAC-SHA256 over the raw body (read with a running 64 KiB cap), a 300 s timestamp tolerance and constant-time comparison, with dual-secret rotation (empty secrets are never candidates), a payload shape check (400, not 500), tag validation, and delivery-id dedupe that records an id only after every purge succeeded, so a failed delivery can be retried.
Not included
Docs versions, reading time, "closest matches" on the 404 page and a copy-code button: hyle does not provide that data or the page would need client JavaScript. Image URLs in content such as /assets/<id> are no longer passed through unchanged: see "Media".
Contract
Types in src/types.ts mirror packages/cp1-read/src/resolve.ts. src/__tests__/contract.test.ts pins them against fixture JSON for every result kind, so a server-side shape change fails a test here.
