@press2ai/engine 0.4.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@press2ai/engine",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "Multi-tenant runtime + template contracts dla otwarty-* verticali. SSR na Cloudflare Workers, isomorphic renderer (SSG/browser w roadmapie).",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -22,10 +22,10 @@
22
22
  * i — fix względem starego trener-landinga — `cities` query też filtruje
23
23
  * po kategorii (wcześniej trener pokazywał miasta z całej tabeli leads,
24
24
  * mieszając wszystkie wertykale).
25
- * - Opt-out jest **globalny** i **hard delete**: DELETE FROM leads + wpis
26
- * w `opted_out_nips` (blacklist NIP-ów żeby sync nie wprowadził z powrotem).
27
- * RODO art. 17 usunięcie danych osobowych, nie soft flag. Z karty profilu
28
- * przez slug (jeden klik), bez formularza NIP.
25
+ * - Opt-out jest **globalny** UPDATE leads bez filtra category. RODO
26
+ * sprzeciw to usunięcie danych osobowych, nie selektywne ukrycie w jednej
27
+ * kategorii. Soft flag + filtr w queries hard delete bez proof-of-ownership
28
+ * jest podatny na sabotage (patrz NEXT-STEPS manifest-1/manifest-2 notatka).
29
29
  *
30
30
  * Hono jest peer dependency (każdy wertykal i tak go ma). Engine core nie
31
31
  * importuje Hono — tylko ten subpath go używa.
@@ -54,6 +54,7 @@ export type CeidgLead = {
54
54
  claimed: number;
55
55
  external_site_url: string | null;
56
56
  fetched_at: number;
57
+ opted_out_at: number | null;
57
58
  };
58
59
 
59
60
  export type CeidgBindings = { DB: D1Database; CACHE: KVNamespace };
@@ -108,15 +109,17 @@ export function createVerticalApp(config: VerticalConfig): Hono<{ Bindings: Ceid
108
109
  // ─────────── SQL ───────────
109
110
  // EXISTS subselect zamiast JOIN: lead z N PKDs nie multiplikuje wierszy.
110
111
  // (SELECT pkd ... LIMIT 1) wybiera jeden PKD per lead — wystarczy do
111
- // pkdToJobTitle. Świadome uproszczenie: lead z mieszanymi PKDs dostanie
112
- // jeden label. Filtr opted-out zbędny opt-out to hard delete (0010).
112
+ // pkdToJobTitle. To jest świadomy uproszczenie: lead z mieszanymi PKDs
113
+ // (np. fizjoterapia + psychologia) dostanie tylko jeden label.
113
114
  const leadsQuery = `
114
115
  SELECT l.nip, l.first_name, l.last_name, c.name as city,
115
116
  l.company_name, l.slug, l.claimed, l.external_site_url, l.fetched_at,
117
+ l.opted_out_at,
116
118
  (SELECT pkd FROM lead_pkd WHERE nip = l.nip LIMIT 1) as pkd
117
119
  FROM leads l
118
120
  LEFT JOIN cities c ON l.city_id = c.id
119
- WHERE EXISTS (SELECT 1 FROM lead_categories WHERE lead_nip = l.nip AND category = ?)`;
121
+ WHERE l.opted_out_at IS NULL
122
+ AND EXISTS (SELECT 1 FROM lead_categories WHERE lead_nip = l.nip AND category = ?)`;
120
123
 
121
124
  // Cities query filtruje po kategorii — fix względem starego trener landinga
122
125
  // który pokazywał miasta z całej tabeli leads (mieszane wertykale).
@@ -125,6 +128,7 @@ export function createVerticalApp(config: VerticalConfig): Hono<{ Bindings: Ceid
125
128
  FROM leads l
126
129
  JOIN cities c ON l.city_id = c.id
127
130
  INNER JOIN lead_categories lc ON l.nip = lc.lead_nip AND lc.category = ?
131
+ WHERE l.opted_out_at IS NULL
128
132
  GROUP BY c.name ORDER BY count DESC LIMIT 8`;
129
133
 
130
134
  // ─────────── Content loader ───────────
@@ -258,15 +262,10 @@ export function createVerticalApp(config: VerticalConfig): Hono<{ Bindings: Ceid
258
262
  }
259
263
  app.get('/opt-out', (c) => c.html(renderStatic(pages.optOutForm)));
260
264
 
261
- // One-click opt-out: przycisk na karcie profilu POST-uje slug. Hard delete
262
- // z `leads` (cascade do `lead_pkd`/`lead_categories`) + insert do blacklisty
263
- // `opted_out_nips` żeby sync nie wprowadził NIP-a z powrotem. Rate limit
264
- // 5/h/IP przeciw abuse — slug jest publiczny, ktokolwiek może nukeować,
265
- // ale katalog to publiczne wizytówki z CEIDG, nie konta.
266
265
  app.post('/opt-out', async (c) => {
267
266
  const form = await c.req.formData();
268
- const slug = String(form.get('slug') ?? '').trim();
269
- if (!slug) return c.text('Brak slug', 400);
267
+ const nip = String(form.get('nip') ?? '').replace(/\D/g, '');
268
+ if (nip.length !== 10) return c.text('Nieprawidłowy NIP', 400);
270
269
 
271
270
  const ip = c.req.header('cf-connecting-ip') ?? '0.0.0.0';
272
271
  const rlKey = `vertical:${category}:rl:optout:${ip}`;
@@ -274,21 +273,20 @@ export function createVerticalApp(config: VerticalConfig): Hono<{ Bindings: Ceid
274
273
  if (rlVal >= OPTOUT_LIMIT) return c.text('Zbyt wiele prób. Spróbuj ponownie za godzinę.', 429);
275
274
  await c.env.CACHE.put(rlKey, String(rlVal + 1), { expirationTtl: OPTOUT_WINDOW_SEC });
276
275
 
277
- const row = await c.env.DB
278
- .prepare('SELECT nip FROM leads WHERE slug = ?')
279
- .bind(slug)
280
- .first<{ nip: string }>();
281
- if (!row) return c.text('Nie znaleziono wpisu', 404);
282
-
283
- const now = Math.floor(Date.now() / 1000);
284
- const ipHash = await sha256Hex(ip + ':' + slug);
285
- await c.env.DB.batch([
286
- c.env.DB.prepare('INSERT OR IGNORE INTO opted_out_nips (nip, opted_out_at, ip_hash) VALUES (?, ?, ?)').bind(row.nip, now, ipHash),
287
- c.env.DB.prepare('DELETE FROM lead_categories WHERE lead_nip = ?').bind(row.nip),
288
- c.env.DB.prepare('DELETE FROM lead_pkd WHERE nip = ?').bind(row.nip),
289
- c.env.DB.prepare('DELETE FROM leads WHERE nip = ?').bind(row.nip),
290
- ]);
291
-
276
+ // Opt-out jest GLOBALNY — usuwa też z innych wertykali. RODO sprzeciw to
277
+ // usunięcie danych osobowych, nie selektywne ukrycie w jednej kategorii.
278
+ // Soft flag (nie DELETE) — defense in depth przeciw masowej sabotage:
279
+ // catalog.json emituje slug+imię+miasto bez NIP, więc adversary musi
280
+ // zmapować slug→NIP przez CEIDG pod rate limit (6000 req/h); dodatkowo
281
+ // soft flag jest natychmiast odwracalny jednym UPDATE gdyby coś poszło nie tak.
282
+ const res = await c.env.DB
283
+ .prepare(
284
+ 'UPDATE leads SET opted_out_at = ?, opted_out_ip = ?, opted_out_ua = ? WHERE nip = ? AND opted_out_at IS NULL',
285
+ )
286
+ .bind(Math.floor(Date.now() / 1000), ip, c.req.header('user-agent') ?? '', nip)
287
+ .run();
288
+
289
+ if (!res.meta.changes) return c.text('Nie znaleziono wpisu', 404);
292
290
  return c.html(renderStatic(pages.optOutDone));
293
291
  });
294
292
 
@@ -310,8 +308,3 @@ export function createVerticalApp(config: VerticalConfig): Hono<{ Bindings: Ceid
310
308
 
311
309
  return app;
312
310
  }
313
-
314
- async function sha256Hex(input: string): Promise<string> {
315
- const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(input));
316
- return Array.from(new Uint8Array(buf)).map((b) => b.toString(16).padStart(2, '0')).join('');
317
- }