@broberg/legal
Status: 0.8.0. The five general documents are v1.0, approved by the owner's lawyer (2/11 2026): draft: false, version and «gælder fra» 2026-11-02. The sixth, modules (0.8.0), is the owner's own text decision of 5/11 2026 and was deliberately NOT sent to a lawyer.
One set of legal documents for every broberg.ai product: terms, privacy, trust, dpa and
support. The text is the same everywhere. Danish is the master and prevails; English sits beside it.
components writes the text; each product fills in its OWN facts and shows the documents on its OWN
site. legal.broberg.ai is an API, not a website: anything there that is not the API sends visitors
to broberg.ai.
MUST: how a product shows its legal documents
Every product with a visible service shows these documents and never writes its own (owner decision D-7effa0). This is a fixed onboarding step for every new project (the TERMS/GDPR card). Six steps:
1. Write your product's facts, once, in YOUR repo
The texts have open slots (facts blocks) for what differs per product. You fill them with a
ProductFacts object kept in your own repo, for example legal/facts.ts:
import type { ProductFacts } from "@broberg/legal";
export const FACTS: ProductFacts = {
id: "jam", // your product id: lower-case, stable
name: "Jam", // the name users know
hosting: [ // where data is stored and processed
{ what: "Appen", where: "Stockholm, Sverige (EU)" },
{ what: "Databasen", where: "Amsterdam, Holland (EU)" },
],
dataCategories: [ // what personal data you hold
"Navn, e-mail og profilbillede fra login",
"Dit indhold, som du selv gemmer",
],
subprocessors: [ // every vendor that touches personal data
{ name: "Fly.io", purpose: "Drift af app og database", location: "Sverige og Holland", transfer: "Databehandleraftale med EU-standardkontrakter" },
],
conversationAnalysis: true, // does the four-part conversation analysis apply?
healthData: false, // health data (GDPR art. 9)? physio apps: read docs/guides/health-data-physio.md
};
- List only facts that are TRUE today. If you are unsure, leave it out and ask; never guess.
- Facts are data, never prose. Do not type a vendor name or an exception into the text. The package turns your facts into tables and sentences through fixed templates.
- The facts change when your product changes (a new vendor, a new region). Update the file in the same change.
- Prove your facts with a test (recommended; jam does this): for every vendor in
subprocessors, require evidence in your code, such as an import (/mistral/i), an env key (R2_ACCESS_KEY_ID) orprimary_regionin fly.toml. Then the facts cannot promise a vendor you do not use, and adding a new integration fails the test until the facts file says so too.
2. Get the texts: the npm package (recommended for JS/TS)
import { getDocument, renderHtml } from "@broberg/legal"; // pin an exact version
import { FACTS } from "./legal/facts";
const doc = getDocument({ doc: "privacy", locale: "da", facts: FACTS });
doc.title; doc.version; doc.effectiveFrom; doc.draft; doc.contentHash;
renderHtml(doc); // semantic HTML with data-legal-id on every section and block; style it yourself
What every legal page MUST carry (checked daily, F110.11): the element that holds the document
has data-legal-doc="<terms|privacy|trust|dpa|support>" AND data-legal-version="<version>", and
every paragraph keeps its data-legal-id with the text verbatim. renderHtml does all three; if you
render the blocks yourself, keep them. Style and layout are yours. scripts/check-legal-pages.mjs
fetches your pages and goes red — naming you — on a missing page, a missing marker, an old version
or an edited paragraph.
The texts ship inside the package, so your site needs no network for them and cannot lose them when legal.broberg.ai is down. New texts reach you as a new package version; components sends a release report saying whether the wording changed.
Want new texts without redeploying? (recommended for a running server; ported from jam)
import { createLegalCache } from "@broberg/legal";
const legal = createLegalCache({ facts: FACTS }); // once, at start
const doc = legal.get("privacy", "da"); // on every page view: never waits, never fails
get answers at once from the latest good template, starting with the one bundled in the package.
At most once a day per document it checks the API in the background (If-None-Match). It swaps in
a newer template only if that template verifies AND resolves with your facts. Any failure keeps
the old one. The cache lives in memory, so a restart starts again from the bundled texts.
3. Or get them over HTTP (any stack)
GET https://legal.broberg.ai/v1/documents → { documents: [{ doc, locale, version, effectiveFrom, draft, hash }] }
GET https://legal.broberg.ai/v1/{doc}.{da|en}.json → the template: sections with facts slots open, + version, draft, stale, hash
Fill the slots with your facts: in JS/TS, resolveTemplate(template, FACTS) from this package. In
another language, render each facts block like this: hosting is a table (What | Where);
dataCategories is a list; subprocessors is a table (Vendor | Used for | Where | Transfer);
conversationAnalysis is one sentence saying whether your product is covered by the section on
conversation analysis. Since 0.6.0 all five documents exist in English (locale: "en"), block for
block the same as the Danish — same ids, same order — so a link or a diff addresses the same paragraph
in both languages. stale: true means the English text is older than the Danish one, so show the
Danish one or a notice. The Danish version prevails if the two differ (stated in the documents
themselves). parityProblems(sources) is the check that keeps every translation in step.
4. Cache: keep your own copy, and only fetch a new edition when there is one (HTTP route)
Your terms page must never go down because legal.broberg.ai is down or being updated.
- Keep a snapshot in your repo or build (
legal/snapshot/{doc}.{locale}.json, the templates as fetched). Your pages render from the snapshot. They never call the API on a page view. - Check for a new edition at most once a day:
GET /v1/documentsand compare eachhashwith your snapshot's. Same hash means there is nothing to do. - Only when a hash changed: fetch that template and check it: in JS/TS with
verifyTemplate(t), and itshashmust equal the one in the list. Then replace the snapshot file in one step (write to a temp file, then rename). Trap: the hash covers the template WITHOUT the response's ownstaleandhashfields. Recomputing it over the whole response never matches. Without the package: drop those two fields, then take sha256 of the canonical JSON (keys sorted, no spaces). - If anything fails (timeout, 5xx, a check fails), keep the old snapshot, log it, and try again tomorrow. Never empty a page because a fetch failed.
- Every answer carries an
ETagandcache-control: public, max-age=86400. A request withIf-None-Match: "<hash>"answers an empty304when nothing changed.
The service runs cold: the first call after a quiet spell takes a second or two. That is fine for a daily check and one more reason never to call it on a page view.
5. Show the documents on YOUR site
- One page per document on your own domain, for example
/vilkaar,/privatliv,/tillid.dpaandsupportbelong in customer contracts (Contract Manager fills{customer.name}and the other placeholders); a public site showsterms,privacyandtrust. - Style and re-flow freely. Never edit the text. Drawing the blocks (
p,note,list,table) yourself in your own design instead of usingrenderHtmlis fully fine, as long as the text is verbatim.renderHtmlis a convenience, not a requirement. - Show the version and the «gælder fra» date (
version,effectiveFrom). Whiledraftis true, show the «UDKAST» marker (renderHtmldoes it for you). - Link all of them in your footer. Never link users to legal.broberg.ai; it redirects to broberg.ai.
6. Acceptance and consent: always «Samtykke» in BID
Every app with login stores BOTH terms acceptances AND GDPR consents in BID («Samtykke» / «Consent» on the BID account), and nowhere else (owner's rule, 1/11 2026; components F110.6). Withdrawing a consent also goes through BID. Never keep your own table for either.
-
Terms are accepted at sign-up with an unticked «Jeg accepterer vilkårene» checkbox. Your app SERVER records it in BID with the user's access token:
POST /api/app/terms-acceptance { doc: "terms", version, contentHash: "sha256:<hex>" }. It reads the latest withGET /api/app/terms-acceptance?doc=terms. Ask again only when the current terms arematerial: trueand newer than the user's latest acceptance. -
The privacy policy is never accepted. It is linked and shown.
-
GDPR consent (for example the physio apps' part-4 analysis) is its own thing: voluntary, never a condition of use, and as easy to withdraw as to give. Record it with
POST /api/app/consents { purpose, granted: true|false, version, contentHash }. Before you use the data, read the current state withGET /api/app/consents?purpose=…. The user can withdraw in the app AND on their BID account; a withdrawal made in BID shows up on your next GET. Purposes come from a fixed list in BID; ask bid to add yours. -
The app derives the product from your login client, so you can only touch your own users and your own product.
-
Register your document pages with bid (for example
terms → https://jams.party/vilkaar) when you start recording acceptances. BID's «Samtykke» page links «Læs vilkårene» to that address; an app never sends a URL itself. -
The routes are live (BID 2/11 2026), and this package has the calls (server-side, with the user's access token):
import { getAcceptance, needsReaccept, recordAcceptance, recordConsent, getConsent, getTemplate } from "@broberg/legal"; const terms = getTemplate({ doc: "terms", locale: "da" }); const latest = await getAcceptance({ accessToken }, "terms"); if (needsReaccept(latest, terms)) { /* show the unticked checkbox; on accept: */ await recordAcceptance({ accessToken }, { doc: "terms", version: terms.version, contentHash: terms.hash }); } const consent = await getConsent({ accessToken }, "health-text-analysis"); // read BEFORE using the data await recordConsent({ accessToken }, { purpose: "health-text-analysis", granted: false, version, contentHash }); // withdrawEach record call returns the row BID stored, and throws
BidConsentErrorif that row does not match what you sent. Errors carrystatusandcode(invalid_token,invalid_doc, …). BID's «Samtykke» page shows only the version the user accepted; whether they must accept again is your app's question, answered byneedsReaccept. Rows stay (with only the user id) if the account is deleted (owner 2/11 2026).
Reference
getDocument({ doc, locale, facts }): a finished document from the bundled templates plus your facts.getTemplate({ doc, locale }): the template, as the API serves it.listDocuments(): what/v1/documentsserves.resolveTemplate(template, facts): a finished document from a template you fetched. It gives the samecontentHashasgetDocumentwith the same facts.renderHtml(document): semantic HTML.EXAMPLE_FACTS: a filled-in example.createLegalCache({ facts, fetch?, refreshMs?, baseUrl?, onKeepOld? })→{ get(doc, locale), settled(doc, locale) }.verifyTemplate(t): true when a fetched template has sections and its hash matches its authored content.LEGAL_API_URL: the API's address, in one place.- contentHash = sha256 of the canonical JSON of the finished document; hash = the same for a template.
- Placeholders (
{customer.name}and the rest) are a fixed list; an unknown one is refused. - Zero dependencies (a built-in SHA-256, proven against FIPS vectors and node:crypto).
0.5.0 (F110.3): v1.0 — the owner's lawyer has reviewed and approved all five documents. draft is now false everywhere (no «UDKAST» marker), and version/effectiveFrom move to 2026-11-02. The wording is unchanged. Terms are material, so needsReaccept answers true for anyone who accepted the draft (2026-11-01): they are asked once to accept the approved v1.0, which is the point. The version stays an ISO date on purpose — it is compared as a string, and «1.0» would sort before «2026-11-01» and silently skip that re-acceptance.
0.4.0 (F110.6): BID calls: recordAcceptance/getAcceptance, recordConsent/getConsent, needsReaccept, BidConsentError, BID_URL. Additive.
0.3.1 (F110.16): terms (da) «Dit indhold» no longer promises «pseudonymiseret analyse» for every product. Whether a product analyses is said only in the privacy policy, per product (facts.conversationAnalysis). The terms text and its hash changed (still a draft). Found by jam.
0.3.0 (F110.15): createLegalCache (ported from jam's F001.79), verifyTemplate, LEGAL_API_URL;
README §4 trap fixed. Additive; the texts are unchanged.
0.2.0 (F110.14), breaking: the fleet product registry and library are gone, and
getDocument takes facts instead of product. The per-product HTTP routes and the human pages are
gone too: legal.broberg.ai is an API.
Plan: docs/features/F110-legal-documents.md, docs/features/F110.14-legal-is-an-api.md.
Terms for module owners and developers — doc: "modules" (0.8.0, F110.17)
For a platform where clinics and developers publish modules (an App Store). The text says the
platform answers for stability and may act on a module that breaks the guidelines: notice →
warning with a deadline → pause → removal, and an immediate pause in serious cases (data at
risk, a security hole, instability, illegal content) — always after a person has assessed it.
During a pause the data is kept and the clinics pay nothing for the module. Complaints, a log of
every step, payment and distribution, re-acceptance on material change (AcceptedDoc includes
"modules"), Danish law.
No number is in the text. Every figure comes from the product's own facts and appears in one table («Tal og frister»):
getDocument({ doc: "modules", locale: "da", facts: { ...myFacts, modules: {
revenueShare: [{ party: "platformen", percent: 30 }, { party: "modulejeren", percent: 70 }], // must add up to 100
operationsFee: "…", paymentProvider: "Mollie", payoutCadence: "månedligt",
guidelinesNoticeDays: 30, warningDeadlineDays: 14, guidelinesUrl: "https://…/retningslinjer",
complaints: "…", complaintsResponse: "3 hverdage", exportDays: 30,
} } });
A product without modules facts gets an error, never a document with an empty table. BID must
accept "modules" as a document type before acceptances can be recorded (asked of bid, 5/11).
Pseudonymisation — @broberg/legal/pseudonymise (0.7.0, F110.8)
Run every conversation through this BEFORE it enters a 6-month analysis copy. Techniques and
standards: docs/legal/pseudonymisation-standards.md. Pseudonymised data is still personal data.
import { createConversation, toAnalysisRecord, deleteExpired } from "@broberg/legal/pseudonymise";
const key = process.env.ANALYSIS_PSEUDONYM_KEY!; // one domain, vault-held, never next to the copy
const convo = createConversation({ key, knownNames: [sender.name, ...parties] /*, findNames */ });
const rec = await toAnalysisRecord({ id, subjectId: user.id, text, collectedAt: new Date() }, convo, key);
// rec.subject = keyed pseudonym · rec.text = names → [PERSON-n], CPR → [CPR], e-mail/phone/IP → keyed markers,
// health sentences → [HELBRED FJERNET] · rec.keepUntil ≤ 6 months · rec.notExamined = what was NOT checked
await deleteExpired(store, new Date()); // your store: listExpired(now) + delete(ids)
- Same person, same pseudonym under one key; another key → unrelated pseudonyms (rotation unlinks).
- Names: the ones you pass in
knownNamesare replaced consistently through one conversation (full name, first name alone, genitive «Mettes»). Names you did not pass are NOT found unless you givefindNames(e.g. an EU model through@broberg/ai-sdk) — and until thennotExaminedcarries"names-not-known". Measured: 16/16 on the Danish known-name corpus in the tests. - Health (art. 9): sentences matching a Danish health vocabulary are removed unless
health: "consented"(explicit consent). It is a keyword step:"health-beyond-keywords"says so. - Addresses and indirect identifiers are not examined (
notExamined).