@broberg/legal

中文文档
0.8.0发布于 2026/11/05MITTypeScriptESM + CJS构建来源已验证近 30 天下载 1804

One set of legal documents for every broberg.ai product — terms, privacy, trust, DPA, support agreement — as structured, versioned, content-hashed data in Danish (master) and English. Sites style and re-flow; they never edit. Zero dependencies.

原始使用文档
文档目录10 节
  1. MUST: how a product shows its legal documents
  2. 1. Write your product's facts, once, in YOUR repo
  3. 2. Get the texts: the npm package (recommended for JS/TS)
  4. 3. Or get them over HTTP (any stack)
  5. 4. Cache: keep your own copy, and only fetch a new edition …
  6. 5. Show the documents on YOUR site
  7. 6. Acceptance and consent: always «Samtykke» in BID
  8. Reference
  9. Terms for module owners and developers — doc: "modules" (0.…
  10. Pseudonymisation — @broberg/legal/pseudonymise (0.7.0, F110…

@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.


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) or primary_region in 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.
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.

  1. 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.
  2. Check for a new edition at most once a day: GET /v1/documents and compare each hash with your snapshot's. Same hash means there is nothing to do.
  3. Only when a hash changed: fetch that template and check it: in JS/TS with verifyTemplate(t), and its hash must 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 own stale and hash fields. 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).
  4. 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.
  5. Every answer carries an ETag and cache-control: public, max-age=86400. A request with If-None-Match: "<hash>" answers an empty 304 when 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. dpa and support belong in customer contracts (Contract Manager fills {customer.name} and the other placeholders); a public site shows terms, privacy and trust.
  • Style and re-flow freely. Never edit the text. Drawing the blocks (p, note, list, table) yourself in your own design instead of using renderHtml is fully fine, as long as the text is verbatim. renderHtml is a convenience, not a requirement.
  • Show the version and the «gælder fra» date (version, effectiveFrom). While draft is true, show the «UDKAST» marker (renderHtml does it for you).
  • Link all of them in your footer. Never link users to legal.broberg.ai; it redirects to broberg.ai.

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 with GET /api/app/terms-acceptance?doc=terms. Ask again only when the current terms are material: true and 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 with GET /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 }); // withdraw
    

    Each record call returns the row BID stored, and throws BidConsentError if that row does not match what you sent. Errors carry status and code (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 by needsReaccept. 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/documents serves.
  • resolveTemplate(template, facts): a finished document from a template you fetched. It gives the same contentHash as getDocument with 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 knownNames are replaced consistently through one conversation (full name, first name alone, genitive «Mettes»). Names you did not pass are NOT found unless you give findNames (e.g. an EU model through @broberg/ai-sdk) — and until then notExamined carries "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).

版本与发布记录

@broberg/legal 共发布 10 个版本,首次发布于 2026/11/01;过去 12 个月发布了 10 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
0.8.0latest2026/11/054 天前
0.7.02026/11/027 天前
0.6.02026/11/027 天前
0.5.02026/11/027 天前
0.4.02026/11/027 天前
0.3.12026/11/028 天前

关于 @broberg/legal 的常见问题

@broberg/legal 是什么?

One set of legal documents for every broberg.ai product — terms, privacy, trust, DPA, support agreement — as structured, versioned, content-hashed data in Danish (master) and English. Sites style and re-flow; they never edit. Zero dependencies.

如何安装 @broberg/legal?

在项目目录运行 npm install @broberg/legal,或使用 pnpm add / yarn add / bun add。

@broberg/legal 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@broberg/legal 支持 ESM 还是 CommonJS?

同时提供 ES Module 与 CommonJS 入口,import 和 require() 都可以使用,打包工具会自动选择合适的版本。

@broberg/legal 可以商用吗?

@broberg/legal 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@broberg/legal 的下载量有多少?

根据 npm 官方下载统计,@broberg/legal 在 2026/10/09 – 2026/11/07 这 30 天内被下载 1,804 次,日均约 60 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@broberg/legal 还在维护吗?

最新版本 0.8.0 发布于 2026/11/05(4 天前);至今共发布 10 个版本,其中过去 12 个月发布了 10 个;首次发布于 2026/11/01。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。