Generate tracker policy document from banner snapshot

Add a markdown "Cookie and Tracking Technologies Policy" template in
the policy-writer tone that covers cookies and other tracking
technologies, with GDPR, UK GDPR, FADP, and CCPA/CPRA privacy-rights
framing. Convert it to ProseMirror JSON and add PublishTrackerPolicy,
which builds the document from a banner's latest published version
snapshot and its tracker third parties. The document is GENERATED and
private in the trust center, linked to the banner via
policy_document_id.

Add CookieItem.HumanizedDuration so server-rendered lifetimes match the
banner's humanizeDuration helper, and reword the publish trigger and
backfill comment to reflect the broader tracker scope.

Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
Émile Ré
2026-06-01 18:20:13 +02:00
parent f6eeeb675f
commit d68df53a6d
6 changed files with 451 additions and 2 deletions

View File

@@ -966,7 +966,7 @@ func (s *Service) PublishCookieBannerVersion(
banner := coredata.CookieBanner{ID: bannerID}
if err := banner.SetPolicyGenerationRequested(ctx, tx); err != nil {
return fmt.Errorf("cannot request cookie policy generation: %w", err)
return fmt.Errorf("cannot request tracker policy generation: %w", err)
}
return nil

View File

@@ -20,6 +20,7 @@ import (
"errors"
"fmt"
"maps"
"strings"
"time"
"github.com/jackc/pgx/v5"
@@ -39,6 +40,12 @@ type (
CookieItems []CookieItem
cookieDurationUnit struct {
secs int
name string
snap int
}
CookieCategory struct {
ID gid.GID `db:"id"`
OrganizationID gid.GID `db:"organization_id"`
@@ -57,6 +64,67 @@ type (
CookieCategories []*CookieCategory
)
// cookieDurationUnits mirrors DURATION_UNITS in
// packages/cookie-banner/src/cookie-utils.ts (and the snap table used by the
// pattern-analysis worker) so durations rendered server-side match exactly what
// visitors see in the consent banner. snap is the per-unit buffer: when the
// remainder is within snap seconds of the next whole unit, round up instead of
// carrying into smaller units.
var cookieDurationUnits = [...]cookieDurationUnit{
{365 * 24 * 3600, "year", 21 * 24 * 3600},
{30 * 24 * 3600, "month", 2 * 24 * 3600},
{7 * 24 * 3600, "week", 12 * 3600},
{24 * 3600, "day", 2 * 3600},
{3600, "hour", 5 * 60},
{60, "minute", 5},
{1, "second", 0},
}
// HumanizedDuration renders the cookie's max-age into a human-readable lifetime
// using the same snapping and composition rules as the banner's
// humanizeDuration helper. A nil or non-positive max-age denotes a session
// cookie that is cleared when the browser closes.
func (c CookieItem) HumanizedDuration() string {
if c.MaxAgeSeconds == nil || *c.MaxAgeSeconds <= 0 {
return "Session"
}
remaining := *c.MaxAgeSeconds
var parts []string
for _, u := range cookieDurationUnits {
if remaining < u.secs-u.snap {
continue
}
count := remaining / u.secs
leftover := remaining - count*u.secs
switch {
case leftover >= u.secs-u.snap:
count++
remaining = 0
case leftover <= u.snap:
remaining = 0
default:
remaining = leftover
}
if count == 1 {
parts = append(parts, fmt.Sprintf("1 %s", u.name))
} else {
parts = append(parts, fmt.Sprintf("%d %ss", count, u.name))
}
}
if len(parts) == 0 {
return "Session"
}
return strings.Join(parts, ", ")
}
func (c CookieItems) MarshalJSON() ([]byte, error) {
if c == nil {
return []byte("[]"), nil

View File

@@ -16,7 +16,7 @@ ALTER TABLE cookie_banners
ADD COLUMN policy_document_id TEXT,
ADD COLUMN policy_generation_requested_at TIMESTAMP WITH TIME ZONE;
-- Backfill: any banner that already has a published version gets its cookie
-- Backfill: any banner that already has a published version gets its tracker
-- policy generated on the worker's first pass.
UPDATE cookie_banners
SET policy_generation_requested_at = NOW()

View File

@@ -469,6 +469,35 @@ type (
ValidFrom string
ValidUntil string
}
TrackerPolicyData struct {
OrganizationName string
WebsiteOrigin string
GeneratedAt time.Time
PrivacyPolicyURL string
ConsentExpiryDays int
Categories []TrackerPolicyCategory
ThirdParties []TrackerPolicyThirdParty
}
TrackerPolicyCategory struct {
Name string
Description string
Necessary bool
Trackers []TrackerPolicyTracker
}
TrackerPolicyTracker struct {
Name string
Purpose string
Duration string
}
TrackerPolicyThirdParty struct {
Name string
Description string
PrivacyPolicyURL string
}
)
func BoolLabel(v bool) string {

View File

@@ -0,0 +1,58 @@
# Cookie and Tracking Technologies Policy
_Last updated: {{ formatDate .GeneratedAt }}_
This Cookie and Tracking Technologies Policy explains how {{ .OrganizationName }} ("we", "us", or "our") uses cookies and other tracking technologies to recognise you when you visit {{ .WebsiteOrigin }} (the "Website"). It explains what these technologies are and why we use them, as well as the rights and choices you have to control them.
{{ if .PrivacyPolicyURL }}
This policy is part of, and should be read together with, our [Privacy Policy]({{ .PrivacyPolicyURL }}).
{{ end }}
## What are cookies and tracking technologies?
Cookies are small data files that are placed on your device when you visit a website. We also use other technologies that store or retrieve information on your device in a similar way, including browser local storage and session storage, IndexedDB, cache storage, pixels, and software development kits. In this policy we refer to all of these collectively as "trackers".
Trackers set by the website owner are called "first-party" trackers. Trackers set by parties other than the website owner are called "third-party" trackers, and they enable features or functionality provided on or through the Website, such as analytics and content from external services.
## Why we use trackers
We use trackers for several reasons. Some are strictly necessary for technical reasons in order for the Website to operate. Others help us understand how the Website is used and improve your experience. We only place non-essential trackers on your device with your consent, and you can withdraw that consent at any time.
## The trackers we use
The tables below describe the categories of trackers we use on the Website, the purpose of the trackers within each category, and how long each one remains on your device. This list reflects the trackers in our current banner configuration and is updated automatically whenever that configuration changes.
{{ range .Categories }}
### {{ .Name }}{{ if .Necessary }} (always active){{ end }}
{{ with .Description }}{{ . }}
{{ else }}Trackers in this category support the functionality described by its name.
{{ end }}{{ if .Trackers }}
| Tracker | Purpose | Duration |
| --- | --- | --- |
{{ range .Trackers }}| {{ .Name }} | {{ .Purpose }} | {{ .Duration }} |
{{ end }}{{ else }}
We are not currently using any trackers in this category.
{{ end }}{{ end }}
{{ if .ThirdParties }}## Third parties that set trackers
Some trackers described above are placed by third-party services that we rely on to operate and improve the Website. These third parties may use trackers to collect information about your activity across different websites. We encourage you to review their privacy notices to understand how they process your data.
{{ range .ThirdParties }}
- **{{ .Name }}**{{ with .Description }} — {{ . }}{{ end }}{{ with .PrivacyPolicyURL }} ([Privacy Policy]({{ . }})){{ end }}
{{ end }}{{ end }}
## How to control trackers
You can decide whether to accept or reject non-essential trackers at any time through the consent banner displayed on the Website. You can also set or update your preferences by reopening the banner from the Website. Strictly necessary trackers cannot be disabled because the Website cannot function properly without them.
Most web browsers additionally allow you to control cookies and clear locally stored data through their settings. If you choose to reject or delete trackers through your browser, some parts of the Website may no longer function as intended. Your consent choices are remembered for up to {{ .ConsentExpiryDays }} days, after which we will ask for your preferences again.
## Your privacy rights
Depending on where you live, data protection and privacy laws give you rights over how trackers are used. Under the EU and UK General Data Protection Regulation (GDPR and UK GDPR) and the Swiss Federal Act on Data Protection (FADP), we ask for your consent before setting any non-essential tracker, and you may withdraw that consent at any time without affecting the lawfulness of processing carried out beforehand.
Under the California Consumer Privacy Act, as amended by the California Privacy Rights Act (CCPA/CPRA), and comparable laws in other jurisdictions, certain uses of trackers may be considered a "sale" or "sharing" of personal information. Where that is the case, you have the right to opt out, and you can exercise that right through the consent banner on the Website. We aim to honor recognised browser-based opt-out signals where required by applicable law.
## Changes to this policy
We may update this policy from time to time to reflect changes to the trackers we use or for other operational, legal, or regulatory reasons. This policy is regenerated automatically when our tracker configuration changes, so please revisit it periodically. The date at the top indicates when it was last updated.
## Contact us
If you have any questions about our use of cookies or other tracking technologies, please contact us using the details available on the Website.

View File

@@ -0,0 +1,294 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package probo
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"text/template"
"time"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/docgen"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/prosemirror"
)
var trackerPolicyTemplate = template.Must(
template.New("tracker_policy.md.tmpl").
Funcs(template.FuncMap{
"formatDate": func(t time.Time) string {
return t.Format("January 2, 2006")
},
}).
ParseFS(Templates, "templates/tracker_policy.md.tmpl"),
)
// BuildTrackerPolicyDocument renders the tracker policy markdown template for
// the given data and converts it into the ProseMirror JSON expected by
// DocumentVersion.Content.
func BuildTrackerPolicyDocument(data docgen.TrackerPolicyData) (string, error) {
var buf bytes.Buffer
if err := trackerPolicyTemplate.Execute(&buf, data); err != nil {
return "", fmt.Errorf("cannot execute tracker policy template: %w", err)
}
node, err := prosemirror.ParseMarkdown(buf.String())
if err != nil {
return "", fmt.Errorf("cannot convert tracker policy markdown: %w", err)
}
out, err := json.Marshal(node)
if err != nil {
return "", fmt.Errorf("cannot marshal tracker policy prosemirror node: %w", err)
}
return string(out), nil
}
// PublishTrackerPolicy generates (or regenerates) the cookie and tracking
// technologies policy document for a banner from its latest published version
// snapshot. The document is stored as a GENERATED document that is PRIVATE in
// the trust center by default, and is linked to the banner through
// cookie_banners.policy_document_id.
func (s *GeneratedDocumentService) PublishTrackerPolicy(
ctx context.Context,
scope coredata.Scoper,
cookieBannerID gid.GID,
) (*coredata.Document, *coredata.DocumentVersion, error) {
var (
document *coredata.Document
documentVersion *coredata.DocumentVersion
)
err := s.svc.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
banner := &coredata.CookieBanner{}
if err := banner.LoadByID(ctx, tx, scope, cookieBannerID); err != nil {
return fmt.Errorf("cannot load cookie banner: %w", err)
}
organization := &coredata.Organization{}
if err := organization.LoadByID(ctx, tx, scope, banner.OrganizationID); err != nil {
return fmt.Errorf("cannot load organization: %w", err)
}
documentData, err := s.buildTrackerPolicyDocumentData(ctx, scope, tx, organization, banner)
if err != nil {
return fmt.Errorf("cannot build document data: %w", err)
}
prosemirrorJSON, err := BuildTrackerPolicyDocument(documentData)
if err != nil {
return fmt.Errorf("cannot build prosemirror document: %w", err)
}
now := time.Now()
var existingDoc *coredata.Document
if banner.PolicyDocumentID != nil {
doc := &coredata.Document{}
err = doc.LoadByID(ctx, tx, scope, *banner.PolicyDocumentID)
if err != nil && !errors.Is(err, coredata.ErrResourceNotFound) {
return fmt.Errorf("cannot load tracker policy document: %w", err)
}
if err == nil && doc.ArchivedAt == nil {
existingDoc = doc
} else {
banner.PolicyDocumentID = nil
banner.UpdatedAt = now
if err := banner.Update(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot clear tracker policy document reference: %w", err)
}
}
}
if existingDoc == nil {
documentID := gid.New(scope.GetTenantID(), coredata.DocumentEntityType)
document = &coredata.Document{
ID: documentID,
OrganizationID: banner.OrganizationID,
WriteMode: coredata.DocumentWriteModeGenerated,
TrustCenterVisibility: coredata.TrustCenterVisibilityPrivate,
Status: coredata.DocumentStatusActive,
CreatedAt: now,
UpdatedAt: now,
}
if err := document.Insert(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot insert document: %w", err)
}
banner.PolicyDocumentID = &documentID
banner.UpdatedAt = now
if err := banner.Update(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot update tracker policy document reference: %w", err)
}
} else {
document = existingDoc
}
documentVersionID := gid.New(scope.GetTenantID(), coredata.DocumentVersionEntityType)
documentVersion = &coredata.DocumentVersion{
ID: documentVersionID,
OrganizationID: banner.OrganizationID,
DocumentID: document.ID,
Title: "Cookie and Tracking Technologies Policy",
Content: prosemirrorJSON,
Classification: coredata.DocumentClassificationPublic,
DocumentType: coredata.DocumentTypePolicy,
Orientation: coredata.DocumentVersionOrientationPortrait,
CreatedAt: now,
UpdatedAt: now,
}
return s.publishOrRequestApproval(ctx, scope, tx, document, documentVersion, banner.OrganizationID, nil, false, now)
},
)
if err != nil {
return nil, nil, err
}
return document, documentVersion, nil
}
func (s *GeneratedDocumentService) buildTrackerPolicyDocumentData(
ctx context.Context, scope coredata.Scoper,
conn pg.Querier,
organization *coredata.Organization,
banner *coredata.CookieBanner,
) (docgen.TrackerPolicyData, error) {
version := &coredata.CookieBannerVersion{}
if err := version.LoadLatestPublishedByCookieBannerID(ctx, conn, scope, banner.ID); err != nil {
return docgen.TrackerPolicyData{}, fmt.Errorf("cannot load latest published version: %w", err)
}
snapshot, err := version.GetSnapshot()
if err != nil {
return docgen.TrackerPolicyData{}, fmt.Errorf("cannot decode snapshot: %w", err)
}
categories := make([]docgen.TrackerPolicyCategory, 0, len(snapshot.Categories))
for _, c := range snapshot.Categories {
trackers := make([]docgen.TrackerPolicyTracker, 0, len(c.Cookies))
for _, cookie := range c.Cookies {
trackers = append(trackers, docgen.TrackerPolicyTracker{
Name: sanitizeTrackerCell(cookie.Name),
Purpose: trackerPurpose(cookie.Description),
Duration: cookie.HumanizedDuration(),
})
}
categories = append(categories, docgen.TrackerPolicyCategory{
Name: strings.TrimSpace(c.Name),
Description: strings.TrimSpace(c.Description),
Necessary: c.Kind == coredata.CookieCategoryKindNecessary,
Trackers: trackers,
})
}
thirdParties, err := s.buildTrackerPolicyThirdParties(ctx, scope, conn, banner.ID)
if err != nil {
return docgen.TrackerPolicyData{}, err
}
privacyPolicyURL := ""
if snapshot.PrivacyPolicyURL != nil {
privacyPolicyURL = strings.TrimSpace(*snapshot.PrivacyPolicyURL)
}
return docgen.TrackerPolicyData{
OrganizationName: organization.Name,
WebsiteOrigin: banner.Origin,
GeneratedAt: time.Now(),
PrivacyPolicyURL: privacyPolicyURL,
ConsentExpiryDays: snapshot.ConsentExpiryDays,
Categories: categories,
ThirdParties: thirdParties,
}, nil
}
func (s *GeneratedDocumentService) buildTrackerPolicyThirdParties(
ctx context.Context, scope coredata.Scoper,
conn pg.Querier,
cookieBannerID gid.GID,
) ([]docgen.TrackerPolicyThirdParty, error) {
var patterns coredata.TrackerPatterns
thirdPartyIDs, err := patterns.LoadDistinctThirdPartyIDsByCookieBannerID(ctx, conn, scope, cookieBannerID)
if err != nil {
return nil, fmt.Errorf("cannot load distinct third party ids: %w", err)
}
if len(thirdPartyIDs) == 0 {
return nil, nil
}
var thirdParties coredata.ThirdParties
if err := thirdParties.LoadByIDs(ctx, conn, scope, thirdPartyIDs); err != nil {
return nil, fmt.Errorf("cannot load third parties: %w", err)
}
rows := make([]docgen.TrackerPolicyThirdParty, 0, len(thirdParties))
for _, tp := range thirdParties {
row := docgen.TrackerPolicyThirdParty{Name: strings.TrimSpace(tp.Name)}
if tp.Description != nil {
row.Description = collapseWhitespace(*tp.Description)
}
if tp.PrivacyPolicyURL != nil {
row.PrivacyPolicyURL = strings.TrimSpace(*tp.PrivacyPolicyURL)
}
rows = append(rows, row)
}
return rows, nil
}
// trackerPurpose returns a table-safe purpose string for a tracker, falling
// back to a neutral label when no enriched description is available.
func trackerPurpose(description string) string {
cell := sanitizeTrackerCell(description)
if cell == "" {
return "Not specified"
}
return cell
}
// sanitizeTrackerCell makes free-form text safe to embed in a markdown table
// cell: it collapses whitespace (including newlines) and escapes pipe
// characters so they do not break the column layout.
func sanitizeTrackerCell(s string) string {
return strings.ReplaceAll(collapseWhitespace(s), "|", "\\|")
}
func collapseWhitespace(s string) string {
return strings.Join(strings.Fields(s), " ")
}