generated from coulomb/repo-seed
Introduces TPSC for tracking external service dependencies with GDPR
compliance maturity (CNIL/IAPP CMMI scale), pricing model, ToS, and
data retention information across all repos.
Primary data:
- canon/tpsc/{openai,anthropic,gemini,openrouter}-api.yaml — service definitions
- tpsc.yaml in each repo (llm-connect seeded with 4 services)
State-hub additions:
- Migration j7e8f9a0b1c2: tpsc_catalog + tpsc_snapshots + tpsc_entries
- api/models/tpsc.py, api/schemas/tpsc.py, api/routers/tpsc.py
- /tpsc/catalog/, /tpsc/ingest/, /tpsc/snapshots/, /tpsc/report/gdpr endpoints
- 4 MCP tools: register_service, list_services, ingest_tpsc_tool, get_gdpr_report
- scripts/ingest_tpsc.py + make ingest-tpsc[/-all] targets
- Dashboard: tpsc.md page + docs/tpsc.md
GDPR maturity scale: unknown | non_compliant | initial | developing | defined | managed | certified
Warnings triggered at: unknown, non_compliant, initial
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
7.3 KiB
7.3 KiB
title
| title |
|---|
| Third-Party Services (TPSC) |
Third-Party Services Catalog
const API = "http://127.0.0.1:8000";
let apiOk = true;
const catalog = await fetch(`${API}/tpsc/catalog/`)
.then(r => r.json())
.catch(() => { apiOk = false; return []; });
const gdprReport = await fetch(`${API}/tpsc/report/gdpr`)
.then(r => r.json())
.catch(() => ({ warnings: [], by_maturity: {}, total_services: 0, warning_count: 0 }));
const snapshots = await fetch(`${API}/tpsc/snapshots/`)
.then(r => r.json())
.catch(() => []);
// GDPR maturity colour coding (CNIL/IAPP scale)
const maturityColor = {
unknown: "#ef4444", // red
non_compliant: "#dc2626", // deep red
initial: "#f97316", // orange
developing: "#eab308", // amber
defined: "#84cc16", // lime
managed: "#22c55e", // green
certified: "#16a34a", // deep green
};
const maturityLabel = {
unknown: "Unknown",
non_compliant: "Non-Compliant",
initial: "Initial",
developing: "Developing",
defined: "Defined",
managed: "Managed",
certified: "Certified",
};
const WARNING_LEVELS = new Set(["unknown", "non_compliant", "initial"]);
// KPI summary
const warningServices = catalog.filter(s => WARNING_LEVELS.has(s.gdpr_maturity));
const paidServices = catalog.filter(s => ["paid", "usage_based"].includes(s.pricing_model));
${gdprReport.warning_count}
GDPR warnings
${gdprReport.total_services}
Services in catalog
${paidServices.length}
Paid / usage-based
Service Catalog
import {html} from "npm:htl";
function maturityBadge(m) {
const color = maturityColor[m] || "#9ca3af";
const label = maturityLabel[m] || m;
return html`<span style="background:${color}20; color:${color}; border:1px solid ${color}60;
border-radius:4px; padding:2px 8px; font-size:0.78rem; font-weight:600; white-space:nowrap;">${label}</span>`;
}
function pricingBadge(p) {
const colors = { paid: "#7c3aed", usage_based: "#7c3aed", freemium: "#0369a1", free: "#166534", unknown: "#6b7280" };
const c = colors[p] || "#6b7280";
return html`<span style="color:${c}; font-size:0.78rem; font-weight:500;">${p.replace("_", " ")}</span>`;
}
const catalogTable = html`<table style="width:100%; border-collapse:collapse; font-size:0.9rem;">
<thead>
<tr style="border-bottom:2px solid #e5e7eb;">
<th style="text-align:left; padding:8px 12px;">Service</th>
<th style="text-align:left; padding:8px 12px;">Provider</th>
<th style="text-align:left; padding:8px 12px;">Category</th>
<th style="text-align:left; padding:8px 12px;">Pricing</th>
<th style="text-align:left; padding:8px 12px;">GDPR Maturity</th>
<th style="text-align:left; padding:8px 12px;">DPA</th>
</tr>
</thead>
<tbody>
${catalog.map(s => html`<tr style="border-bottom:1px solid #f3f4f6; ${WARNING_LEVELS.has(s.gdpr_maturity) ? 'background:#fff7f7;' : ''}">
<td style="padding:8px 12px; font-weight:500;">
${s.website_url
? html`<a href="${s.website_url}" target="_blank" style="color:#1d4ed8;">${s.name}</a>`
: s.name}
</td>
<td style="padding:8px 12px; color:#6b7280;">${s.provider || "—"}</td>
<td style="padding:8px 12px; color:#6b7280;">${s.category || "—"}</td>
<td style="padding:8px 12px;">${pricingBadge(s.pricing_model)}</td>
<td style="padding:8px 12px;">${maturityBadge(s.gdpr_maturity)}</td>
<td style="padding:8px 12px;">${s.dpa_available ? "✅" : "❌"}</td>
</tr>`)}
</tbody>
</table>`;
display(catalogTable);
GDPR Warnings
Services at Unknown, Non-Compliant, or Initial maturity may limit use in GDPR-regulated or corporate environments.
if (gdprReport.warnings.length === 0) {
display(html`<p style="color:#16a34a;">✅ No GDPR warnings across active repos.</p>`);
} else {
const warningCards = html`<div style="display:flex; flex-direction:column; gap:0.75rem;">
${gdprReport.warnings.map(w => {
const color = maturityColor[w.gdpr_maturity] || "#ef4444";
return html`<div style="border-left:4px solid ${color}; background:${color}10; padding:0.75rem 1rem; border-radius:4px;">
<div style="font-weight:600;">${w.service_slug}
<span style="font-weight:400; color:#6b7280; margin-left:0.5rem;">in ${w.repo_slug || "unknown repo"}</span>
</div>
<div style="font-size:0.85rem; margin-top:2px;">
${maturityBadge(w.gdpr_maturity)}
${w.pricing_model ? html` ${pricingBadge(w.pricing_model)}` : ""}
${w.purpose ? html`<span style="color:#6b7280; margin-left:0.5rem;">— ${w.purpose}</span>` : ""}
</div>
</div>`;
})}
</div>`;
display(warningCards);
}
Per-Repo Breakdown
// Build: latest snapshot per repo → service list
const repoBreakdown = new Map();
for (const snap of snapshots) {
const repoSlug = snap.repo_id || "unknown";
if (!repoBreakdown.has(repoSlug) || snap.snapshot_at > repoBreakdown.get(repoSlug).snapshot_at) {
repoBreakdown.set(repoSlug, snap);
}
}
// Enrich with catalog data
const catalogBySlug = Object.fromEntries(catalog.map(s => [s.slug, s]));
const repoTable = html`<table style="width:100%; border-collapse:collapse; font-size:0.9rem;">
<thead>
<tr style="border-bottom:2px solid #e5e7eb;">
<th style="text-align:left; padding:8px 12px;">Repo</th>
<th style="text-align:left; padding:8px 12px;">Services</th>
<th style="text-align:left; padding:8px 12px;">Ingested</th>
</tr>
</thead>
<tbody>
${[...repoBreakdown.entries()].map(([repoSlug, snap]) => html`<tr style="border-bottom:1px solid #f3f4f6;">
<td style="padding:8px 12px; font-weight:500;">${repoSlug}</td>
<td style="padding:8px 12px;">
${snap.entries.map(e => {
const cat = catalogBySlug[e.service_slug];
const m = cat?.gdpr_maturity || "unknown";
const color = maturityColor[m] || "#9ca3af";
return html`<span style="display:inline-flex; align-items:center; gap:4px; margin:2px 4px 2px 0;
background:${color}15; border:1px solid ${color}50; border-radius:4px; padding:2px 8px; font-size:0.8rem;">
<span style="width:8px; height:8px; border-radius:50%; background:${color}; display:inline-block;"></span>
${e.service_slug}
</span>`;
})}
</td>
<td style="padding:8px 12px; color:#9ca3af; font-size:0.8rem;">${new Date(snap.snapshot_at).toLocaleDateString()}</td>
</tr>`)}
</tbody>
</table>`;
display(repoTable);