From adadc167e8037431b669374164a24eae50a7cb7d Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 4 Nov 2026 10:28:27 +0100 Subject: [PATCH 1/2] Hand news and waitlist sign-ups to Utskick over a service binding /api/signup keeps all its checks: same origin, JSON only, address, list, country, the hidden bot field and one answer for new and known addresses. It then calls env.UTSKICK.subscribe() instead of writing to the site's own D1. UTSKICK is a service binding to the utskick Worker, so the call stays inside Cloudflare. The lists news and gateway become ftw-news and ftw-box in Utskick; the consent ids stay news-2026-10-30 and gateway-2026-10-30. The site passes the visitor's IP address from CF-Connecting-IP as ip, so Utskick limits sign-ups per visitor, not for the whole site. Without the binding, or when Utskick refuses, the endpoint answers 503 and the forms say sign-up is closed. signups.sql goes: Utskick owns the schema. The tests fake the binding, check what the site sends, and check the 503 when Utskick throws. The privacy notice, in English and Swedish, now names the sender as it promised: Utskick, delivering through Amazon SES in Frankfurt, a one-way hash kept after someone leaves, no open tracking, and clicks counted without device data or IP address. Ported from srcfl/utskick docs/examples/ftw-web.patch, which applied cleanly; the ip field, its tests and the Swedish page are new. Co-Authored-By: Claude Opus 5.5 --- README.md | 23 +++++++++------- _worker.js | 12 ++++++--- privacy.html | 5 ++-- signups.sql | 10 ------- sv/privacy.html | 5 ++-- tests/signups.test.mjs | 60 ++++++++++++++++++++++++++++++------------ 6 files changed, 71 insertions(+), 44 deletions(-) delete mode 100644 signups.sql diff --git a/README.md b/README.md index 2ef3c3c..c7d6c49 100644 --- a/README.md +++ b/README.md @@ -94,15 +94,20 @@ after a failed native install. The home page's two forms post to `/api/signup` in `_worker.js`. It accepts same-origin JSON only, checks the address, list and country, ignores a filled -hidden field and answers the same for a new and a known address. Rows go to a -D1 database bound as `SIGNUPS_DB`, kept apart from the anonymous -`FUTURE_DB` votes because it holds email addresses. Production and preview -share the EU database `ftw-signups` -(`32851378-602c-4b71-b3ea-69e85acff569`); its schema is `signups.sql`. -Without the binding the endpoint answers 503 and the forms say sign-up is -closed. Test only with your own address, then delete that row. Each row records the consent -version that `privacy.html#email` describes; change both together. Name the -email service in the privacy notice before the first send. +hidden field and answers the same for a new and a known address. It then hands +the sign-up to Utskick ([srcfl/utskick](https://github.com/srcfl/utskick)), our +newsletter sender, through the service binding `UTSKICK`, which calls Utskick's +`subscribe()` over RPC; the call never leaves Cloudflare and the method cannot +be reached from the internet. The site's `news` and `gateway` lists are +`ftw-news` and `ftw-box` there. The site passes the visitor's IP address as +`ip`, so Utskick limits sign-ups to 10 a minute per visitor; Utskick keeps no +IP address. It keeps the lists in its own EU D1 database and sends through +Amazon SES in Frankfurt. Bind `UTSKICK` in +production only: a preview deployment with the binding would sign people up for +real. Without the binding the endpoint answers 503 and the forms say sign-up is +closed. Test only with your own address, then unsubscribe with the link in the +first email. Each sign-up records the consent version that `privacy.html#email` +describes; change both together. ## Local dev diff --git a/_worker.js b/_worker.js index d76a26c..54f8361 100644 --- a/_worker.js +++ b/_worker.js @@ -43,6 +43,8 @@ async function boundedJSON(request, limit = 512) { // The consent each list records. privacy.html describes both. const lists = { news: 'news-2026-10-30', gateway: 'gateway-2026-10-30' }; +// The same lists by their names in Utskick. +const utskickLists = { news: 'ftw-news', gateway: 'ftw-box' }; const countries = new Set(['SE', 'NO', 'DK', 'FI', 'DE', 'NL', 'EU', 'XX']); const emailPattern = /^[^\s@<>()\[\],;:"]+@[^\s@<>()\[\],;:"]+\.[^\s@<>()\[\],;:"]{2,}$/; @@ -60,11 +62,13 @@ async function signup(request, env, url) { if (country !== null && !countries.has(country)) return json({ error: 'invalid_country' }, 400); // A filled hidden field means a form-filling bot. Answer as if it worked and store nothing. if (body.website) return json({ ok: true }); - if (!env.SIGNUPS_DB) return json({ error: 'signup_unavailable' }, 503); + if (!env.UTSKICK) return json({ error: 'signup_unavailable' }, 503); try { - await env.SIGNUPS_DB.prepare(`INSERT INTO signups (email, list, country, consent) VALUES (?, ?, ?, ?) - ON CONFLICT(email, list) DO UPDATE SET country = COALESCE(excluded.country, signups.country), consent = excluded.consent`) - .bind(email, body.list, country, lists[body.list]).run(); + // Utskick (srcfl/utskick) keeps the lists and sends the email. The call goes over a service + // binding and never leaves Cloudflare. With the visitor's IP address, Utskick limits sign-ups + // per visitor instead of for the whole site; it does not store the address. + await env.UTSKICK.subscribe({ email, list: utskickLists[body.list], country, consent: lists[body.list], source: 'ftw-web', + ip: request.headers.get('CF-Connecting-IP') }); } catch (error) { console.error(JSON.stringify({ event: 'signup_failed', name: error.name })); return json({ error: 'signup_unavailable' }, 503); diff --git a/privacy.html b/privacy.html index cd4b89e..7c0976e 100644 --- a/privacy.html +++ b/privacy.html @@ -44,7 +44,7 @@

FTW apps, Cloud and the FTW plugin

Privacy

-

Updated 30 October 2026

+

Updated 4 November 2026

Your box keeps the energy record; your phone keeps a recent copy. The app reaches your box through FTW Cloud, which carries the connection encrypted end to end and cannot read it. A readable copy is off unless you turn it on. The app does not track you across apps or sites. The planned project statistics are described below.

@@ -113,7 +113,8 @@

Project statistics and the new Cloud terms

FTW news and the FTW box waitlist

On this website you can give us your email address for FTW news, for the FTW box waitlist, or both. We store the address, the list you joined, your country if you chose one, which consent you gave and when. We store nothing else from the form, and no IP address. We use the address only for that list: news about FTW, or an email when FTW boxes go on sale, with the price and shipping. We do not sell it or share it for advertising.

-

The legal basis is your consent. You can leave a list at any time: every email we send will have a link for it, or email support@sourceful.energy. We then delete your address from that list. We delete the waitlist when the box sale ends. The addresses are kept in a Cloudflare D1 database in the EU that Sourceful controls; Cloudflare processes them on our behalf. Before we send the first email, we will name the service that sends it here.

+

The legal basis is your consent. You can leave a list at any time with the link at the bottom of every email, or by emailing support@sourceful.energy. We then delete your address from that list. So that we do not email you again by mistake, we keep a one-way hash of it: a fingerprint we can match a new sign-up against, but cannot turn back into the address. We delete the waitlist when the box sale ends.

+

The addresses are kept in a Cloudflare D1 database in the EU that Sourceful controls; Cloudflare processes them on our behalf. We send the emails with Utskick, our own open source newsletter sender, and they are delivered by Amazon Simple Email Service from Amazon Web Services' Frankfurt region in Germany; Amazon Web Services processes the address and the email on our behalf. When you click a link in one of our emails, the click passes through our own server first, which records which link it was and when, and nothing about your device or IP address. FTW emails have no tracking image, so we do not record whether you opened them. If an email cannot be delivered or is reported as spam, we stop sending to that address.

Your rights

Sourceful Labs AB (reg. no. 559382-0458), Kalmar, Sweden, is responsible for the data FTW Cloud and the relay handle. Under the GDPR you can ask what we hold about you, have it corrected or deleted, and object to how we use it. FTW Cloud holds no name, email address or street address for you, but your home id and IP addresses count as personal data. To ask, email support@sourceful.energy or use Sourceful's contact page. You can also complain to the Swedish Authority for Privacy Protection (IMY).

diff --git a/signups.sql b/signups.sql deleted file mode 100644 index f049f02..0000000 --- a/signups.sql +++ /dev/null @@ -1,10 +0,0 @@ --- News and FTW box waitlist sign-ups, bound to the Pages Worker as SIGNUPS_DB. --- Kept apart from the anonymous future votes: this table holds email addresses. -CREATE TABLE IF NOT EXISTS signups ( - email TEXT NOT NULL, - list TEXT NOT NULL CHECK (list IN ('news', 'gateway')), - country TEXT, - consent TEXT NOT NULL, - created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), - PRIMARY KEY (email, list) -) WITHOUT ROWID; diff --git a/sv/privacy.html b/sv/privacy.html index f1ad005..99fe351 100644 --- a/sv/privacy.html +++ b/sv/privacy.html @@ -44,7 +44,7 @@

FTW-appar, Cloud och FTW-pluginet

Integritet

-

Uppdaterad 30 oktober 2026

+

Uppdaterad 4 november 2026

Boxen sparar energihistoriken och telefonen en aktuell kopia. Appen når boxen genom FTW Cloud, som bär anslutningen krypterad från ände till ände och inte kan läsa den. En läsbar kopia är avstängd om du inte slår på den. Appen spårar dig inte mellan appar eller sajter. Den planerade projektstatistiken beskrivs nedan.

@@ -113,7 +113,8 @@

Projektstatistik och nya Cloud-villkor

Nyheter om FTW och väntelistan för FTW-boxen

På den här webbplatsen kan du lämna din e-postadress för nyheter om FTW, för väntelistan till en FTW-box, eller båda. Vi sparar adressen, vilken lista du valde, landet om du valde ett, vilket samtycke du gav och när. Vi sparar inget annat från formuläret och ingen IP-adress. Vi använder adressen bara för den listan: nyheter om FTW, eller ett mejl när FTW-boxar går att köpa, med pris och frakt. Vi säljer den inte och delar den inte för reklam.

-

Den rättsliga grunden är ditt samtycke. Du kan lämna en lista när du vill: varje mejl vi skickar kommer att ha en länk för det, eller mejla support@sourceful.energy. Då raderar vi adressen från listan. Väntelistan raderar vi när försäljningen av boxen är slut. Adresserna ligger i en databas hos Cloudflare (D1) inom EU som Sourceful styr över, och Cloudflare behandlar dem för vår räkning. Innan vi skickar det första mejlet anger vi här vilken tjänst som skickar det.

+

Den rättsliga grunden är ditt samtycke. Du kan lämna en lista när du vill med länken längst ned i varje mejl, eller genom att mejla support@sourceful.energy. Då raderar vi adressen från listan. För att vi inte ska mejla dig igen av misstag sparar vi en envägshash av den: ett fingeravtryck som vi kan jämföra en ny anmälan med, men som inte går att göra om till adressen. Väntelistan raderar vi när försäljningen av boxen är slut.

+

Adresserna ligger i en databas hos Cloudflare (D1) inom EU som Sourceful styr över, och Cloudflare behandlar dem för vår räkning. Vi skickar mejlen med Utskick, vårt eget nyhetsbrevsverktyg med öppen källkod, och Amazon Simple Email Service levererar dem från Amazon Web Services region i Frankfurt i Tyskland; Amazon Web Services behandlar adressen och mejlet för vår räkning. När du klickar på en länk i ett av våra mejl går klicket först via vår egen server, som noterar vilken länk det var och när, men inget om din enhet eller IP-adress. FTW-mejlen har ingen spårningsbild, så vi noterar inte om du har öppnat dem. Om ett mejl inte kan levereras eller anmäls som skräppost slutar vi skicka till den adressen.

Dina rättigheter

Sourceful Labs AB (org.nr 559382-0458), Kalmar, är personuppgiftsansvarig för det FTW Cloud och reläet hanterar. Enligt GDPR kan du fråga vad vi har om dig, få det rättat eller raderat och invända mot hur vi använder det. FTW Cloud har inget namn, ingen e-postadress och ingen gatuadress för dig, men hemmets id och IP-adresser räknas som personuppgifter. Mejla support@sourceful.energy eller använd Sourcefuls kontaktsida. Du kan också klaga hos Integritetsskyddsmyndigheten (IMY).

diff --git a/tests/signups.test.mjs b/tests/signups.test.mjs index 9ee7733..b1cbc5d 100644 --- a/tests/signups.test.mjs +++ b/tests/signups.test.mjs @@ -1,25 +1,30 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { DatabaseSync } from 'node:sqlite'; -import { readFileSync } from 'node:fs'; import worker from '../_worker.js'; -function fixture({ bound = true } = {}) { - const db = new DatabaseSync(':memory:'); - db.exec(readFileSync(new URL('../signups.sql', import.meta.url), 'utf8')); - const binding = { - prepare(sql) { - return { bind(...params) { return { async run() { db.prepare(sql).run(...params); return { success: true }; } }; } }; +function fixture({ bound = true, fails = null } = {}) { + // Stands in for Utskick's subscribe() on the UTSKICK service binding: one row per list, as Utskick keeps them. + // `calls` keeps every argument the site sent; `fails` makes it throw as Utskick does, e.g. 'rate_limited'. + const lists = new Map(); + const calls = []; + const utskick = { + async subscribe(signup) { + calls.push(signup); + if (fails) throw new Error(fails); + const { email, list, country, consent } = signup; + const key = `${email} ${list}`; + lists.set(key, { email, list, country: country ?? lists.get(key)?.country ?? null, consent }); + return { ok: true }; } }; - const env = { ASSETS: { fetch: () => new Response('static') }, ...(bound ? { SIGNUPS_DB: binding } : {}) }; - const call = (body, { method = 'POST', origin = 'https://ftw.test', type = 'application/json' } = {}) => worker.fetch(new Request('https://ftw.test/api/signup', { + const env = { ASSETS: { fetch: () => new Response('static') }, ...(bound ? { UTSKICK: utskick } : {}) }; + const call = (body, { method = 'POST', origin = 'https://ftw.test', type = 'application/json', ip } = {}) => worker.fetch(new Request('https://ftw.test/api/signup', { method, - headers: { Origin: origin, 'Content-Type': type }, + headers: { Origin: origin, 'Content-Type': type, ...(ip ? { 'CF-Connecting-IP': ip } : {}) }, ...(method === 'POST' ? { body: typeof body === 'string' ? body : JSON.stringify(body) } : {}) }), env); - const rows = () => db.prepare('SELECT email, list, country, consent FROM signups ORDER BY list, email').all().map(r => ({ ...r })); - return { call, rows }; + const rows = () => [...lists.values()].sort((a, b) => a.list.localeCompare(b.list) || a.email.localeCompare(b.email)); + return { call, rows, calls }; } test('a sign-up stores a normalized address on its list with the consent it was given', async () => { @@ -28,7 +33,7 @@ test('a sign-up stores a normalized address on its list with the consent it was assert.equal(response.status, 200); assert.deepEqual(await response.json(), { ok: true }); assert.equal(response.headers.get('cache-control'), 'no-store'); - assert.deepEqual(rows(), [{ email: 'fredrik@example.com', list: 'gateway', country: 'SE', consent: 'gateway-2026-10-30' }]); + assert.deepEqual(rows(), [{ email: 'fredrik@example.com', list: 'ftw-box', country: 'SE', consent: 'gateway-2026-10-30' }]); }); test('the same address can join both lists, and joining again neither duplicates nor reveals it', async () => { @@ -38,11 +43,32 @@ test('the same address can join both lists, and joining again neither duplicates const again = await call({ email: 'A@example.com', list: 'gateway' }); assert.deepEqual(await again.json(), { ok: true }); assert.deepEqual(rows(), [ - { email: 'a@example.com', list: 'gateway', country: 'NO', consent: 'gateway-2026-10-30' }, - { email: 'a@example.com', list: 'news', country: null, consent: 'news-2026-10-30' } + { email: 'a@example.com', list: 'ftw-box', country: 'NO', consent: 'gateway-2026-10-30' }, + { email: 'a@example.com', list: 'ftw-news', country: null, consent: 'news-2026-10-30' } ]); }); +test('Utskick gets the site as source and the visitor\'s IP address, so it limits sign-ups per visitor', async () => { + const { call, calls } = fixture(); + await call({ email: 'a@example.com', list: 'news', country: 'FI' }, { ip: '203.0.113.7' }); + await call({ email: 'b@example.com', list: 'gateway' }); + assert.deepEqual(calls, [ + { email: 'a@example.com', list: 'ftw-news', country: 'FI', consent: 'news-2026-10-30', source: 'ftw-web', ip: '203.0.113.7' }, + // Without the header (never the case behind Cloudflare), Utskick limits the whole site instead. + { email: 'b@example.com', list: 'ftw-box', country: null, consent: 'gateway-2026-10-30', source: 'ftw-web', ip: null } + ]); +}); + +test('when Utskick refuses or fails, the endpoint says sign-up is unavailable and claims nothing', async () => { + for (const code of ['rate_limited', 'invalid_consent']) { + const { call, rows } = fixture({ fails: code }); + const response = await call({ email: 'a@example.com', list: 'news' }, { ip: '203.0.113.7' }); + assert.equal(response.status, 503, code); + assert.deepEqual(await response.json(), { error: 'signup_unavailable' }); + assert.deepEqual(rows(), []); + } +}); + test('bad input is refused and stores nothing', async () => { const { call, rows } = fixture(); const cases = [ @@ -77,7 +103,7 @@ test('a filled hidden field looks accepted but stores nothing', async () => { assert.deepEqual(rows(), []); }); -test('without its database the endpoint says sign-up is unavailable and claims nothing', async () => { +test('without the Utskick binding the endpoint says sign-up is unavailable and claims nothing', async () => { const { call } = fixture({ bound: false }); const response = await call({ email: 'a@example.com', list: 'news' }); assert.equal(response.status, 503); From 3f7c45fc576ac41ffdebbe92e0a8d3046b5d0ba1 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 4 Nov 2026 10:30:01 +0100 Subject: [PATCH 2/2] Say what a click records: whether it looks like a mail server's link check Co-Authored-By: Claude Opus 5.5 --- privacy.html | 2 +- sv/privacy.html | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/privacy.html b/privacy.html index 7c0976e..a9bedc3 100644 --- a/privacy.html +++ b/privacy.html @@ -114,7 +114,7 @@

Project statistics and the new Cloud terms

FTW news and the FTW box waitlist

On this website you can give us your email address for FTW news, for the FTW box waitlist, or both. We store the address, the list you joined, your country if you chose one, which consent you gave and when. We store nothing else from the form, and no IP address. We use the address only for that list: news about FTW, or an email when FTW boxes go on sale, with the price and shipping. We do not sell it or share it for advertising.

The legal basis is your consent. You can leave a list at any time with the link at the bottom of every email, or by emailing support@sourceful.energy. We then delete your address from that list. So that we do not email you again by mistake, we keep a one-way hash of it: a fingerprint we can match a new sign-up against, but cannot turn back into the address. We delete the waitlist when the box sale ends.

-

The addresses are kept in a Cloudflare D1 database in the EU that Sourceful controls; Cloudflare processes them on our behalf. We send the emails with Utskick, our own open source newsletter sender, and they are delivered by Amazon Simple Email Service from Amazon Web Services' Frankfurt region in Germany; Amazon Web Services processes the address and the email on our behalf. When you click a link in one of our emails, the click passes through our own server first, which records which link it was and when, and nothing about your device or IP address. FTW emails have no tracking image, so we do not record whether you opened them. If an email cannot be delivered or is reported as spam, we stop sending to that address.

+

The addresses are kept in a Cloudflare D1 database in the EU that Sourceful controls; Cloudflare processes them on our behalf. We send the emails with Utskick, our own open source newsletter sender, and they are delivered by Amazon Simple Email Service from Amazon Web Services' Frankfurt region in Germany; Amazon Web Services processes the address and the email on our behalf. When you click a link in one of our emails, the click passes through our own server first, which records which link it was and when, and whether the click looks like a mail server's automatic link check rather than a person. To tell, it looks at the network the click comes from and the name your browser gives, and keeps neither, nor your IP address. FTW emails have no tracking image, so we do not record whether you opened them. If an email cannot be delivered or is reported as spam, we stop sending to that address.

Your rights

Sourceful Labs AB (reg. no. 559382-0458), Kalmar, Sweden, is responsible for the data FTW Cloud and the relay handle. Under the GDPR you can ask what we hold about you, have it corrected or deleted, and object to how we use it. FTW Cloud holds no name, email address or street address for you, but your home id and IP addresses count as personal data. To ask, email support@sourceful.energy or use Sourceful's contact page. You can also complain to the Swedish Authority for Privacy Protection (IMY).

diff --git a/sv/privacy.html b/sv/privacy.html index 99fe351..82c20f7 100644 --- a/sv/privacy.html +++ b/sv/privacy.html @@ -114,7 +114,7 @@

Projektstatistik och nya Cloud-villkor

Nyheter om FTW och väntelistan för FTW-boxen

På den här webbplatsen kan du lämna din e-postadress för nyheter om FTW, för väntelistan till en FTW-box, eller båda. Vi sparar adressen, vilken lista du valde, landet om du valde ett, vilket samtycke du gav och när. Vi sparar inget annat från formuläret och ingen IP-adress. Vi använder adressen bara för den listan: nyheter om FTW, eller ett mejl när FTW-boxar går att köpa, med pris och frakt. Vi säljer den inte och delar den inte för reklam.

Den rättsliga grunden är ditt samtycke. Du kan lämna en lista när du vill med länken längst ned i varje mejl, eller genom att mejla support@sourceful.energy. Då raderar vi adressen från listan. För att vi inte ska mejla dig igen av misstag sparar vi en envägshash av den: ett fingeravtryck som vi kan jämföra en ny anmälan med, men som inte går att göra om till adressen. Väntelistan raderar vi när försäljningen av boxen är slut.

-

Adresserna ligger i en databas hos Cloudflare (D1) inom EU som Sourceful styr över, och Cloudflare behandlar dem för vår räkning. Vi skickar mejlen med Utskick, vårt eget nyhetsbrevsverktyg med öppen källkod, och Amazon Simple Email Service levererar dem från Amazon Web Services region i Frankfurt i Tyskland; Amazon Web Services behandlar adressen och mejlet för vår räkning. När du klickar på en länk i ett av våra mejl går klicket först via vår egen server, som noterar vilken länk det var och när, men inget om din enhet eller IP-adress. FTW-mejlen har ingen spårningsbild, så vi noterar inte om du har öppnat dem. Om ett mejl inte kan levereras eller anmäls som skräppost slutar vi skicka till den adressen.

+

Adresserna ligger i en databas hos Cloudflare (D1) inom EU som Sourceful styr över, och Cloudflare behandlar dem för vår räkning. Vi skickar mejlen med Utskick, vårt eget nyhetsbrevsverktyg med öppen källkod, och Amazon Simple Email Service levererar dem från Amazon Web Services region i Frankfurt i Tyskland; Amazon Web Services behandlar adressen och mejlet för vår räkning. När du klickar på en länk i ett av våra mejl går klicket först via vår egen server, som noterar vilken länk det var och när, och om klicket ser ut att vara en mejlservers automatiska länkkontroll snarare än en person. För att avgöra det tittar servern på vilket nät klicket kommer från och vilket namn webbläsaren uppger, men sparar varken det eller din IP-adress. FTW-mejlen har ingen spårningsbild, så vi noterar inte om du har öppnat dem. Om ett mejl inte kan levereras eller anmäls som skräppost slutar vi skicka till den adressen.

Dina rättigheter

Sourceful Labs AB (org.nr 559382-0458), Kalmar, är personuppgiftsansvarig för det FTW Cloud och reläet hanterar. Enligt GDPR kan du fråga vad vi har om dig, få det rättat eller raderat och invända mot hur vi använder det. FTW Cloud har inget namn, ingen e-postadress och ingen gatuadress för dig, men hemmets id och IP-adresser räknas som personuppgifter. Mejla support@sourceful.energy eller använd Sourcefuls kontaktsida. Du kan också klaga hos Integritetsskyddsmyndigheten (IMY).