harvest-feed.js
Rama main · réplica de lectura del repositorio original.
'use strict';
// Outbound harvest feed — push a summary of what has been harvested and what is
// coming to a URL you configure. This module opens connections; it never accepts
// any. No inbound endpoint, no open port, no certificate on this side.
//
// Why a lab might want this: the harvest numbers already live here, and the
// places that need them (your own website, a CSA/box scheme, a co-op listing, a
// chat bot answering "what do you have today?") are somewhere else. Copying them
// by hand goes stale within a day. Pointing those systems at this server instead
// would mean exposing the lab machine to the internet — the wrong trade for a
// handful of numbers.
//
// lab machine ──POST(signed)──▶ whatever you run ──▶ customers
// ◀──── reply ─────
//
// If this machine is off, the receiver keeps the last payload it got; its
// consumers see older numbers instead of an outage.
//
// ── What comes back ──────────────────────────────────────────────────────────
//
// The reply to that POST, and nothing else. It may carry `pickups` — collection
// slots the receiver has taken bookings for — which are stored here and shown
// on the Pickups page. Note what this does and does not change:
//
// still true nothing can reach this machine unbidden. There is no listener,
// no port and no route in; the reply exists only because this
// side asked a question and is still holding the socket open.
// now false "nothing comes back". Data from the far end does reach the
// database, which makes the reply a trust boundary — see "The
// reply" further down for what that costs and what it buys.
//
// A push is finished the moment the receiver answers 2xx. Whatever is in the
// body is a second, separate question, and a bad answer to it never turns a
// delivered payload into a failed one.
//
// ── What leaves the building ─────────────────────────────────────────────────
//
// Species, optionally strain, gram totals, and dates. No batch ids, no bag ids,
// no customers, no scan history, no notes. The payload is small on purpose:
// anything that leaves is something you have to reason about, and a summary is
// far easier to reason about than a dump.
//
// ── What this module deliberately does NOT do ────────────────────────────────
//
// It does not estimate yields. Planned entries carry a species and a date, never
// an amount: how much a block will actually give varies too much between flushes
// to put a number on it, and a number that arrives at a customer becomes a
// promise. Recorded harvests are measured, so those carry grams.
//
// It does not subtract reservations. A lab that has promised half its Thursday
// harvest to a restaurant should publish the remainder, not the total — but what
// counts as promised is a business rule that differs per lab and is not tracked
// here. Do that subtraction in the receiving system, where the commitments live.
//
// ── Configuration ────────────────────────────────────────────────────────────
//
// Normally: Settings → Harvest feed, in the browser. That writes one row and
// restarts the timer; nothing needs a shell or a restart of the server.
//
// The environment variables below still work and are the fallback whenever the
// stored config is off. They exist for installs where the configuration is baked
// into an image or handled by whatever starts the process — there, a value that
// can be changed through the web UI is the wrong shape.
//
// One place wins at a time, and which one is visible in Settings. Splitting a
// single setting across two sources is how you end up with a feed pointing
// somewhere nobody can find.
//
// HARVEST_WEBHOOK_URL where to POST. Required — unset means off.
// HARVEST_WEBHOOK_SECRET HMAC key. Required; see "Signature" below.
// HARVEST_WEBHOOK_INTERVAL_MIN how often, in minutes (default 15, min 1).
// HARVEST_WEBHOOK_FRESH_DAYS how far back a harvest still counts as
// available (default 3). Fresh produce; set it
// to what your product actually keeps.
// HARVEST_WEBHOOK_PLANNED_DAYS how far ahead to report upcoming batches
// (default 28). 0 turns the planned block off.
// HARVEST_WEBHOOK_LEAD_DAYS days between a batch's due date (end of
// incubation) and the first expected flush
// (default 0). Species-dependent; yours is
// whatever your records show.
// HARVEST_WEBHOOK_STRAIN 1 (default) includes strain names, 0 sends
// only the species.
// HARVEST_WEBHOOK_SITE free-form label, passed through untouched.
// Useful when several sites post to one
// receiver.
// HARVEST_WEBHOOK_PACK_SIZES the portions you hand a release out in, in
// grams: "250,500,1000". One ladder for every
// species — see `packSizes` below. Unset means
// no instruction, and the receiver decides.
// HARVEST_WEBHOOK_TIMEOUT_MS per attempt (default 15000).
//
// ── Signature ────────────────────────────────────────────────────────────────
//
// Every request carries:
//
// X-Meistertracker-Timestamp: <unix seconds>
// X-Meistertracker-Signature: sha256=<hex>
//
// where the hex is HMAC-SHA256 over `${timestamp}.${body}` using the shared
// secret. Signing the timestamp *with* the body is what makes a captured request
// useless later: the receiver rejects anything older than its own tolerance, and
// the timestamp cannot be edited without breaking the signature. Same
// construction as this server's own GitHub webhook check, in the other
// direction.
//
// The secret is not optional. An unsigned feed is one anyone can forge, and a
// forged "we have 40 kg" is worse than no feed at all.
const crypto = require('crypto');
// Guard against a hung POST stacking up behind a short interval. One in flight
// at a time; a skipped tick is harmless because the next one carries the same
// (or fresher) numbers — this is a snapshot, not an event log.
let inFlight = false;
let timer = null;
// Fassung 1 carried `harvested`, and a receiver was free to publish it. Fassung 2
// says a `released` list is present and that it, not `harvested`, is what may be
// offered for sale. Ignoring that difference means publishing produce the grower
// deliberately kept back, so it is a version bump and not a new optional field.
//
// ⚠️ **Fassung 1 is no longer sent.** It used to be the default, with release
// mode as a switch, and that switch was the bug: a harvest total is a number
// that stops being true the moment something is sold at the stall, and no
// setting makes it true again. Worse, it was the *quiet* option — a lab that
// never found the checkbox published raw stock and looked fine doing it. What
// may be sold is now always a decision somebody made by hand, and an empty
// `released` list means exactly that: nothing is for sale yet.
//
// `VERSION` stays as the number Fassung 1 carried, so a receiver reading old
// records still knows what it is looking at.
const VERSION = 1;
const VERSION_RELEASE = 2;
const ATTEMPTS = 3;
// ── Pack sizes ───────────────────────────────────────────────────────────────
//
// `released` says how much may be sold. `packSizes` says in what portions it is
// handed over: [250, 500, 1000] — grams, ascending, one ladder for every
// species.
//
// It is a separate statement because it answers a different question, and the
// far end was answering it alone. A shop has to offer *some* amount, so without
// this it invents a ladder — 250 g and multiples of it, say. That is a
// fair guess and still a guess: a farm packing 400 g trays has no way to say so.
//
// **One list, not one per species.** Portioning follows the packing bench —
// which trays are on the shelf, what the scale steps in — not the mushroom in
// the tray. Per species it would be a field to fill in for every new Sorte, for
// an answer that is the same every time, and the first one forgotten would be a
// species a shop quietly offers in the wrong sizes.
//
// **Absent means absent.** No sizes set, no field in the payload — and a
// receiver has nothing to sell produce in. That is the intended direction, and
// it is deliberately not a quiet one: a receiver is meant to stop taking
// orders entirely rather than fall back on a ladder of its own. A fallback
// is the problem — multiples of 250 g that nobody decided on, sitting in a
// shop window looking exactly as authoritative as a
// figure somebody chose. Same lesson as the release switch above.
//
// Not a version bump: a receiver that ignores the field is correct, only less
// specific. Fassung 2 stays Fassung 2.
const PACK_MIN_G = 25;
const PACK_MAX_G = 25_000;
const PACK_MAX_COUNT = 8;
/**
* The canonical form of a pack-size list: ascending, deduplicated, in range.
*
* Takes what a form, an environment variable or an API body might hold — an
* array, "250,500,1000", a single number — and returns integers. The one place
* that decides what a size may be; everything else stores or sends the result.
*
* Silent about what it drops, on purpose. This runs on the way in from a UI
* that shows the result back, and on every read of a stored row where throwing
* would take the settings page down over a stray comma.
*/
function packSizes(raw) {
const parts = Array.isArray(raw) ? raw : String(raw ?? '').split(',');
const out = [];
for (const part of parts) {
// Number and not parseInt. parseInt('500g') is 500 and parseInt('2 kg') is
// 2 — it reads until it stops understanding and keeps what it had, which
// turns a unit somebody typed out of habit into a portion size two grams
// large. Number says NaN to both, and NaN is dropped.
const n = Number(String(part).trim());
if (!Number.isInteger(n) || n < PACK_MIN_G || n > PACK_MAX_G) continue;
if (!out.includes(n)) out.push(n);
}
return out.sort((a, b) => a - b).slice(0, PACK_MAX_COUNT);
}
/**
* The URL check, shared by both config sources.
*
* Plain HTTP would put the payload and the signature on the wire in clear.
* Loopback stays allowed so the thing can be tried out against a local receiver
* before a certificate exists.
*/
function checkUrl(url, label) {
let parsed;
try {
parsed = new URL(url);
} catch {
throw new Error(label + ' is not a URL: ' + url);
}
// Credentials in the URL would be copied into the config row and, from there,
// into every "Harvest feed started" log line. The secret is deliberately kept
// out of the logs; a password smuggled in via the URL would walk straight
// back in. The receiver authenticates the HMAC, so it never needs these.
if (parsed.username || parsed.password)
throw new Error(label + ' must not carry credentials in the URL — the feed authenticates with the shared secret');
// URL.hostname keeps the brackets on an IPv6 literal ('[::1]', not '::1'), so
// comparing against the bare form never matched and http://[::1]:PORT/ was
// rejected as "not https" — the one loopback address this is meant to allow.
const host = parsed.hostname;
const loopback = host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]';
if (parsed.protocol !== 'https:' && !(parsed.protocol === 'http:' && loopback))
throw new Error(label + ' must be https (http is allowed for localhost only): ' + url);
}
/** Read config from an env-like object. Returns null when the feed is off. */
function readConfig(env) {
const url = String(env.HARVEST_WEBHOOK_URL || '').trim();
if (!url) return null;
const secret = String(env.HARVEST_WEBHOOK_SECRET || '').trim();
if (!secret)
throw new Error('HARVEST_WEBHOOK_URL is set but HARVEST_WEBHOOK_SECRET is not — refusing to send unsigned');
checkUrl(url, 'HARVEST_WEBHOOK_URL');
return {
url,
secret,
intervalMs: Math.max(1, num(env.HARVEST_WEBHOOK_INTERVAL_MIN, 15)) * 60 * 1000,
freshDays: Math.max(0, num(env.HARVEST_WEBHOOK_FRESH_DAYS, 3)),
plannedDays: Math.max(0, num(env.HARVEST_WEBHOOK_PLANNED_DAYS, 28)),
leadDays: Math.max(0, num(env.HARVEST_WEBHOOK_LEAD_DAYS, 0)),
// Strain names are a competitive detail for some labs and the whole point of
// the listing for others ("Blue oyster" is not "oyster"). Default on,
// because a receiver can always drop a field it does not want, while it
// cannot invent one it never got.
strain: String(env.HARVEST_WEBHOOK_STRAIN ?? '1') !== '0',
site: String(env.HARVEST_WEBHOOK_SITE || '').trim(),
packSizes: packSizes(env.HARVEST_WEBHOOK_PACK_SIZES),
timeoutMs: Math.max(1000, num(env.HARVEST_WEBHOOK_TIMEOUT_MS, 15000)),
source: 'env'
};
}
/** Turn a stored row (db.getHarvestFeedCfg) into the same shape. */
function storedConfig(row) {
if (!row || !row.enabled) return null;
const url = String(row.url || '').trim();
if (!url) throw new Error('Harvest feed is enabled but no URL is set');
const secret = String(row.secret || '').trim();
if (!secret) throw new Error('Harvest feed is enabled but no secret is set — refusing to send unsigned');
checkUrl(url, 'Harvest feed URL');
return {
url,
secret,
intervalMs: Math.max(1, Number(row.intervalMin) || 15) * 60 * 1000,
freshDays: Math.max(0, Number(row.freshDays) || 0),
plannedDays: Math.max(0, Number(row.plannedDays) || 0),
leadDays: Math.max(0, Number(row.leadDays) || 0),
strain: row.strain !== false,
site: String(row.site || '').trim(),
// Re-checked, not trusted. The stored text is canonical when it was written
// through the settings page, and this file must still be right for a row
// somebody edited with sqlite3.
packSizes: packSizes(row.packSizes),
timeoutMs: 15000,
source: 'db'
};
}
/**
* Which config actually applies.
*
* Stored config wins when it is on, environment otherwise. Not merged: a URL
* from one place and a secret from the other is a configuration nobody can read
* off a single screen, and the failure mode is a feed posting to a stale
* receiver with nothing in the UI to explain it.
*/
function resolveConfig({ database, env, dbApi }) {
if (database && dbApi && typeof dbApi.getHarvestFeedCfg === 'function') {
const fromDb = storedConfig(dbApi.getHarvestFeedCfg(database));
if (fromDb) return fromDb;
}
return readConfig(env || process.env);
}
function num(raw, fallback) {
const n = parseInt(raw, 10);
return Number.isFinite(n) ? n : fallback;
}
/** YYYY-MM-DD, `days` away from `from`. Dates only — no clock, no timezone. */
function shiftDate(from, days) {
const d = new Date(from.getTime());
d.setUTCDate(d.getUTCDate() + days);
return d.toISOString().slice(0, 10);
}
/**
* Today, in the machine's own timezone. Twin of localDay() in db.js.
*
* Deliberately not the UTC day the window arithmetic above uses. A window that
* ends two hours early costs nothing; a release does not expire until the end of
* the day someone typed, and east of Greenwich the UTC day ends first — "valid
* until Saturday" would keep selling into Sunday morning. The direction of the
* error is what decides this, not tidiness.
*/
function localDay(at) {
const p = (n) => String(n).padStart(2, '0');
return `${at.getFullYear()}-${p(at.getMonth() + 1)}-${p(at.getDate())}`;
}
// How far ahead pickup windows are published. Not configurable, unlike the
// harvest horizons: those answer "how much of our production do we show", which
// is a business decision per lab. This one answers "how far ahead can somebody
// book", and four weeks is long enough for any market rhythm while keeping a
// recurring block from expanding into thousands of rows.
const PICKUP_WINDOW_DAYS = 28;
// A market lab has a handful of windows a week; four weeks of six-a-day is 168.
// Anything past this is a runaway recurrence rather than a real schedule.
const MAX_PICKUP_WINDOWS = 500;
// The zone the calendar's timed events are written in — see customEventToVEVENT,
// which hardcodes the same one on every VEVENT. Sent explicitly so the receiver
// never has to guess: "09:00" without a zone is not a time, and a window that
// silently moves by an hour in October is a missed handover.
const PICKUP_TZ = 'Europe/Berlin';
/** Comma-separated exception dates, as stored. Twin of db.parseExceptionDates. */
function exceptionSet(raw) {
if (!raw) return new Set();
return new Set(
String(raw)
.split(',')
.map((s) => s.trim())
.filter(Boolean)
);
}
/** UTC-only date arithmetic: YYYY-MM-DD in, YYYY-MM-DD out. */
function addDaysStr(dateStr, n) {
const d = new Date(dateStr + 'T00:00:00Z');
d.setUTCDate(d.getUTCDate() + n);
return d.toISOString().slice(0, 10);
}
/**
* n months after `base`, clamping to the target month's last day.
*
* ⚠️ Computed from the base every time, never cumulatively from the previous
* occurrence: adding a month to 31 January overflows to 3 March, which both
* skips February and shifts every later occurrence onto the 3rd. Twin of
* addMonthsClamped() in app.js, which carries the same warning.
*/
function addMonthsStr(baseStr, n) {
const base = new Date(baseStr + 'T00:00:00Z');
const m = base.getUTCMonth() + n;
const y = base.getUTCFullYear() + Math.floor(m / 12);
const tm = ((m % 12) + 12) % 12;
const lastDay = new Date(Date.UTC(y, tm + 1, 0)).getUTCDate();
const day = Math.min(base.getUTCDate(), lastDay);
return new Date(Date.UTC(y, tm, day)).toISOString().slice(0, 10);
}
/**
* The dates a calendar event actually falls on, inside [from, until].
*
* A recurring block is one row with many occurrences, and `exception_dates`
* means "this one does not exist" — a holiday, or a market that was cancelled.
*/
function occurrenceDates(ev, from, until) {
const skip = exceptionSet(ev.exception_dates);
const out = [];
if (!ev.recurrence) {
if (ev.start_date >= from && ev.start_date <= until && !skip.has(ev.start_date)) out.push(ev.start_date);
return out;
}
const hardEnd = ev.recurrence_until || null;
let cur = ev.start_date;
let monthIdx = 0;
let guard = 0;
while (guard++ < 1000) {
if (cur > until) break;
if (hardEnd && cur > hardEnd) break;
if (cur >= from && !skip.has(cur)) out.push(cur);
if (ev.recurrence === 'daily') cur = addDaysStr(cur, 1);
else if (ev.recurrence === 'weekly') cur = addDaysStr(cur, 7);
else if (ev.recurrence === 'monthly') cur = addMonthsStr(ev.start_date, ++monthIdx);
else break;
}
return out;
}
/**
* The id one window is known by, on both sides, for good.
*
* ⚠️ Event id **plus occurrence date**. A recurring block is a single row, so
* keying on the row alone makes every Friday the same window — one booking then
* occupies all of them, and the mistake only shows up at a handover point.
*
* Kept inside `[A-Za-z0-9_-]{1,64}`, which is the narrowest charset any receiver
* is likely to impose (the one this feed is built against does). Ids from
* imported CalDAV events can carry `@` and `.`, so they are folded — and folding
* two different ids onto the same string would silently merge two windows, hence
* the digest tail whenever anything had to change.
*/
function windowId(eventId, date) {
const raw = String(eventId);
const safe = raw.replace(/[^A-Za-z0-9_-]/g, '-');
if (safe === raw && safe.length <= 43) return safe + '_' + date;
const digest = crypto.createHash('sha256').update(raw).digest('hex').slice(0, 8);
return safe.slice(0, 34) + '-' + digest + '_' + date;
}
/**
* When goods can be collected — the lab's opening times, stated positively.
*
* ⚠️ **Title and description are not read here, and must not be.** They are free
* text in an internal tool and will end up holding staff notes ("Erika covers,
* Max is off"). What leaves is the shape of the window: when, where, how many.
* A receiver composes its own label from that.
*
* The list is complete for its horizon and replaces whatever the receiver holds
* — it is not a set of changes. Empty therefore means "nothing bookable", which
* is a real answer and must survive the trip; the field is always present.
*/
function buildPickupWindows(database, at) {
const rows = collectPickupWindows(database, at);
if (rows === null) return null;
// Named one by one rather than spread with the internal fields removed. This
// is the boundary the whole feature is careful about, and an allow-list keeps
// it careful: a field added upstream has to be added here on purpose before it
// can leave the building. `event` is the one dropped today — the lab's own
// screens key on it (see pickupWindowIndex), and it is nobody else's business.
return rows.map((w) =>
trim({
id: w.id,
date: w.date,
from: w.from,
to: w.to,
tz: w.tz,
place: w.place ?? null,
capacity: w.capacity ?? null
})
);
}
/**
* The same windows, with the row each one came from.
*
* For this end only. The warning before a booked window is moved has to find
* the bookings, and a booking names the window by the id below — so the mapping
* from "calendar event on this date" to "that id" must exist exactly once, and
* this is it.
*/
function pickupWindowIndex(database, at) {
return collectPickupWindows(database, at) || [];
}
function collectPickupWindows(database, at) {
const from = localDay(at);
const until = addDaysStr(from, PICKUP_WINDOW_DAYS);
let rows;
try {
rows = database
.prepare(
`SELECT e.id, e.start_date, e.start_time, e.end_time, e.all_day,
e.recurrence, e.recurrence_until, e.exception_dates, e.pickup_capacity,
l.name AS place
FROM calendar_events e
LEFT JOIN pickup_locations l ON l.id = e.location_id
WHERE e.category = 'pickup'
ORDER BY e.start_date, e.start_time`
)
.all();
} catch {
// A database that predates the pickup columns. The harvest numbers are the
// point of this payload and must still go out.
return null;
}
const windows = [];
for (const ev of rows) {
// A window without a clock is not a window: "Saturday" cannot be booked
// into, and inventing 00:00–23:59 would publish an opening time nobody
// stated. The editor keeps a pickup entry from being all-day; this is the
// guard for rows that predate it or arrived over CalDAV.
if (ev.all_day === 1 || !ev.start_time || !ev.end_time) continue;
for (const date of occurrenceDates(ev, from, until)) {
windows.push(
trim({
id: windowId(ev.id, date),
event: ev.id,
date,
from: ev.start_time,
to: ev.end_time,
tz: PICKUP_TZ,
// The name alone. The address stays in the building — it is on the
// location row for the lab's own calendar clients, not for this.
place: ev.place || null,
// Absent means uncapped. 0 is a different answer — the window exists
// and takes nobody — and survives trim() because only null does not.
capacity: ev.pickup_capacity === null || ev.pickup_capacity === undefined ? null : ev.pickup_capacity
})
);
if (windows.length >= MAX_PICKUP_WINDOWS) break;
}
if (windows.length >= MAX_PICKUP_WINDOWS) break;
}
windows.sort((a, b) => (a.date + a.from).localeCompare(b.date + b.from) || a.id.localeCompare(b.id));
return windows;
}
/**
* The payload, built straight from the database.
*
* Split in two on purpose, and the two halves mean different things:
*
* harvested — weighed and recorded. A number you can sell against.
* planned — a batch whose incubation ends soon. A hint, with no amount.
*
* Anything that blurs the two ends up promising produce that does not exist yet.
*/
function buildPayload(database, cfg, now) {
const at = now || new Date();
const since = shiftDate(at, -cfg.freshDays) + 'T00:00:00';
// Aggregate in SQL rather than pulling every harvest row into JS: on a server
// with years of records that is the difference between a few rows and all of
// them, every interval.
const harvested = database
.prepare(
`SELECT species, strain, SUM(grams) AS grams, MAX(time) AS last
FROM harvests
WHERE time >= ? AND species IS NOT NULL AND species <> ''
GROUP BY species, strain
HAVING SUM(grams) > 0
ORDER BY species, strain`
)
.all(since)
.map((r) =>
trim({
species: r.species,
strain: cfg.strain ? r.strain || null : null,
grams: Math.round(r.grams),
lastHarvest: r.last || null
})
);
const planned = [];
if (cfg.plannedDays > 0) {
const today = at.toISOString().slice(0, 10);
// `due` is the end of incubation, not the harvest — hence leadDays. Compare
// against the un-shifted window and shift the reported date, so a lead time
// never silently widens how far ahead we look.
const until = shiftDate(at, cfg.plannedDays);
// ⚠️ `date(b.due)`, not `b.due`. The column holds a full timestamp — it is
// written with toISOString() — so `b.due + 'T00:00:00Z'` produced
// "2026-08-05T14:23:11.123ZT00:00:00Z", an invalid Date, and the
// toISOString() below threw "Invalid time value". That took the *whole*
// payload down: one planned batch and the harvested half never left either.
//
// Normalising in SQL fixes two things at once. Rows whose date cannot be
// read yield NULL and drop out instead of throwing, and `BETWEEN` on a
// plain date stops cutting the last day short — a timestamp on the final
// day sorts after the bare date and was silently excluded.
// GROUP BY, because one entry per batch is a different statement than one
// entry per offer. Four blocks of the same species due the same day used to
// produce four identical entries, so anyone reading the feed could count
// batches — and this feed deliberately reports no amounts at all. It is a
// cadence signal rather than a volume one (batch sizes differ severalfold),
// but it is still a number nobody meant to publish, and on the receiving
// end it lists the same offer four times.
//
// `harvested` already draws this line: one entry per species, on the
// grounds that splitting by batch would "split one offer in two".
const rows = database
.prepare(
`SELECT b.species, b.strain, date(b.due) AS due_date
FROM batches b
WHERE b.batch_type = 'block'
AND date(b.due) BETWEEN ? AND ?
AND b.species IS NOT NULL AND b.species <> ''
AND EXISTS (SELECT 1 FROM bags g WHERE g.batch_id = b.batch_id)
AND NOT EXISTS (SELECT 1 FROM harvests h WHERE h.batch = b.batch_id)
GROUP BY b.species, b.strain, date(b.due)
ORDER BY date(b.due), b.species`
)
.all(today, until);
const seen = new Set();
for (const r of rows) {
// Belt and braces: date() already filtered the unreadable ones, and a
// single bad row must never cost the whole feed.
const tag = new Date(r.due_date + 'T00:00:00Z');
if (Number.isNaN(tag.getTime())) continue;
const entry = trim({
species: r.species,
strain: cfg.strain ? r.strain || null : null,
expectedFrom: shiftDate(tag, cfg.leadDays)
});
// The SQL grouping is not enough on its own: with strain names switched
// off, two strains of one species collapse into the same entry only after
// the name is dropped here. Dedupe on what actually goes out.
const key = JSON.stringify(entry);
if (seen.has(key)) continue;
seen.add(key);
planned.push(entry);
}
}
const payload = {
version: VERSION_RELEASE,
generatedAt: at.toISOString(),
freshDays: cfg.freshDays,
harvested,
planned,
// A separate list, not a field on `harvested`. The two answer different
// questions — what came off the racks, and what may be sold — and only the
// first is production data. Keeping them apart also lets a release outlive
// its harvest window: set two kilos aside on Monday for a Saturday market
// and by Thursday the harvest has aged out of `freshDays`, while the crate
// is still standing there. The human who put it there is the better source.
//
// Always present, empty list included: "nobody has released anything" is an
// answer, and a receiver has to be able to tell it apart from "this lab does
// not do releases". The second reading is what let raw harvest totals reach
// a shop window.
released: database
.prepare(
`SELECT species, grams, valid_until AS validUntil
FROM harvest_release
WHERE grams > 0 AND (valid_until IS NULL OR valid_until >= ?)
ORDER BY species`
)
.all(localDay(at))
.map((r) => trim({ species: r.species, grams: Math.round(r.grams), validUntil: r.validUntil || null }))
};
// Alongside `released`, never inside it: the same ladder for every species is
// the whole point, and a copy per entry invites a receiver to read the two as
// independent. Omitted when nothing is set — see "Pack sizes" up top.
const sizes = packSizes(cfg.packSizes);
if (sizes.length) payload.packSizes = sizes;
// When goods can be collected. Always present once this build can compute it,
// empty list included: that is what tells a receiver the difference between
// "this lab publishes no windows" and "this lab's software cannot state any",
// and only the second one justifies falling back to a hand-kept list.
//
// No version bump for it. Fassung 2 changed what an existing field *meant*, so
// ignoring it was unsafe; a new field is not, and bumping would have made the
// receiver's deployment a flag day — it rejects versions it does not know.
const windows = buildPickupWindows(database, at);
if (windows) payload.pickupWindows = windows;
// The ids we hold and have not confirmed yet, so the receiver can stop
// repeating them. Omitted entirely when there is nothing to confirm: a
// receiver that knows nothing about pickups should never see the field, and
// an empty array is a claim ("I have none") where absence is silence.
//
// Bookings and withdrawals go in the same list. From the receiver's side
// there is one question — did the other end take this in? — and it has to
// stop repeating either kind on the same answer.
//
// Sending is not confirming. These are marked as confirmed only after a push
// carrying them actually succeeded — see receive().
//
// ⚠️ Twin of db.unackedPickupIds(). A test asserts the two agree; changing
// what counts as unconfirmed means changing it in both.
try {
const done = database
.prepare(
`SELECT id FROM (
SELECT id, received AS ord FROM pickups WHERE acked_at IS NULL
UNION ALL
SELECT id, at AS ord FROM pickup_cancellations WHERE acked_at IS NULL
) ORDER BY ord LIMIT ?`
)
.all(MAX_ACK_PER_PUSH)
.map((r) => r.id);
if (done.length) payload.pickupsDone = [...new Set(done)];
} catch {
// No pickups tables yet — a database mid-migration, or one this feature has
// never run against. The harvest numbers are the point of this payload and
// they must still go out.
}
if (cfg.site) payload.site = cfg.site;
return payload;
}
/** Drop null fields so the receiver never has to distinguish absent from null. */
function trim(obj) {
const out = {};
for (const [k, v] of Object.entries(obj)) if (v !== null && v !== undefined) out[k] = v;
return out;
}
/** `sha256=<hex>` over `${timestamp}.${body}` — see "Signature" up top. */
function sign(secret, timestamp, body) {
return 'sha256=' + crypto.createHmac('sha256', secret).update(`${timestamp}.${body}`).digest('hex');
}
// ── The reply ────────────────────────────────────────────────────────────────
//
// Everything above this line goes out. What follows comes back, and it is the
// only place in this program where data from the far end travels towards the
// lab machine — so it is treated as hostile until every field has proven
// otherwise. Four rules, in order of how much they save you:
//
// 1. A push that got a 2xx has succeeded. What is in the body is a second and
// separate question, and no answer to it may turn a delivered payload into
// a failed one. The numbers are out; that was the job.
// 2. The body is capped *before* it is read, not measured after. A receiver
// that answers with a gigabyte should cost a few kilobytes and a log line.
// 3. Anything not named below is dropped — not stored-and-ignored, dropped.
// 4. Every length and every count is bounded, because "the receiver would not
// do that" is not a property of a network.
const REPLY_MAX_BYTES = 64 * 1024;
const MAX_PICKUPS = 200;
// Withdrawals are bare ids, so a reply can hold as many as it has bookings —
// the whole open list could be cancelled at once.
const MAX_CANCELLED = 200;
const MAX_ITEMS_PER_PICKUP = 50;
// How many confirmations ride along on one push. A backlog larger than this
// drains over the following ticks rather than inflating a single request.
const MAX_ACK_PER_PUSH = 500;
// The receiver assigns these. Narrow on purpose: an id is a key, not a label,
// and this covers what a key needs while leaving nothing to escape into.
const ID_RE = /^[A-Za-z0-9_.:@#/+-]{1,128}$/;
// Local wall-clock — no offset, no Z. The zone travels in its own field, and a
// value that looks like an instant would invite exactly the conversion that
// must not happen.
const LOCAL_TIME_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2})?$/;
// "Europe/Berlin", "UTC".
const ZONE_RE = /^[A-Za-z][A-Za-z0-9_+-]*(\/[A-Za-z0-9_+-]+){0,2}$/;
/** A bounded string, or null. Control characters never make it through. */
function str(v, max) {
if (typeof v !== 'string') return null;
const s = v.replace(/[\x00-\x1f\x7f]/g, ' ').trim();
return s ? s.slice(0, max) : null;
}
/**
* One pickup, rebuilt field by field from an untrusted object.
*
* Rebuilt, not filtered: the result is a fresh object holding only what was
* recognised, so a field nobody thought about cannot ride along to whatever
* reads this next. Returns null when the entry is unusable, which for anything
* without a valid id it is — the id is what makes storing it twice safe.
*/
function onePickup(raw) {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
const id = typeof raw.id === 'string' ? raw.id.trim() : '';
if (!ID_RE.test(id)) return null;
const out = { id, overbooked: raw.overbooked === true };
const order = str(raw.order, 64);
if (order) out.order = order;
const slot = str(raw.slot, 64);
if (slot) out.slot = slot;
const slotText = str(raw.slotText, 120);
if (slotText) out.slotText = slotText;
const place = str(raw.place, 120);
if (place) out.place = place;
const from = str(raw.from, 32);
if (from && LOCAL_TIME_RE.test(from)) out.from = from;
const to = str(raw.to, 32);
if (to && LOCAL_TIME_RE.test(to)) out.to = to;
const zone = str(raw.zone, 64);
if (zone && ZONE_RE.test(zone)) out.zone = zone;
const items = [];
if (Array.isArray(raw.items)) {
for (const it of raw.items.slice(0, MAX_ITEMS_PER_PICKUP)) {
if (!it || typeof it !== 'object') continue;
const kind = str(it.kind, 80);
// A line with no name is not a line, and a weight that is negative or
// measured in tonnes is a bug at the far end. Either would end up on a
// picking list, so neither is stored.
if (!kind) continue;
const grams = Number(it.grams);
if (!Number.isFinite(grams) || grams < 0 || grams > 10_000_000) continue;
// `kind` is what the customer was shown ("Igelstachelbart"); `species` is
// the string this database keys a release on ("Igelstachelbart (IGS)").
// They differ on purpose — a shop does not print our shorthand — and the
// mapping between them lives in the receiver, not here.
//
// Without it the two halves of the Pickups page cannot be added up: the
// release table is keyed one way and the bookings the other, so nobody
// can see how much of a release is already spoken for. Optional, because
// a receiver that predates the field is not wrong, only less useful.
const species = str(it.species, 120);
items.push(species ? { kind, species, grams: Math.round(grams) } : { kind, grams: Math.round(grams) });
}
}
if (items.length) out.items = items;
return out;
}
/**
* Validate a reply body. Never throws; returns whatever survived.
*
* `dropped` is not decoration for a diagnostics screen. A receiver whose
* pickups all fail validation looks exactly like a receiver that has none, and
* those two need very different fixing.
*/
function parseReply(text) {
const empty = { pickups: [], cancelled: [], dropped: 0 };
if (!text) return empty;
let raw;
try {
raw = JSON.parse(text);
} catch {
// Not repeated into the log: a body that is not JSON is frequently an HTML
// error page, and the interesting part is that it was not JSON.
return { ...empty, error: 'reply is not JSON' };
}
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return { ...empty, error: 'reply is not an object' };
// The two lists are read independently and neither can take the other down
// with it. They say different things — this is open, this is withdrawn — and
// a receiver that garbles one has still told the truth with the other.
const problems = [];
let dropped = 0;
const pickups = [];
// No pickups field at all is the ordinary case: every receiver written before
// this existed answers that way, and it is not an error.
if (raw.pickups !== undefined && raw.pickups !== null) {
if (!Array.isArray(raw.pickups)) problems.push('pickups is not a list');
else {
// Entries past the cap are never examined, only counted.
const over = Math.max(0, raw.pickups.length - MAX_PICKUPS);
if (over) problems.push(`reply carried more than ${MAX_PICKUPS} pickups`);
dropped += over;
const seen = new Set();
for (const entry of raw.pickups.slice(0, MAX_PICKUPS)) {
const p = onePickup(entry);
if (!p) {
dropped++;
continue;
}
// The same id twice in one reply: first wins, so which one that is does
// not depend on the order rows happen to be written in.
if (seen.has(p.id)) {
dropped++;
continue;
}
seen.add(p.id);
pickups.push(p);
}
}
}
// Withdrawals: a bare list of ids, nothing else. There is nothing to describe
// about a pickup that is no longer happening.
const cancelled = [];
if (raw.pickupsCancelled !== undefined && raw.pickupsCancelled !== null) {
if (!Array.isArray(raw.pickupsCancelled)) problems.push('pickupsCancelled is not a list');
else {
const over = Math.max(0, raw.pickupsCancelled.length - MAX_CANCELLED);
if (over) problems.push(`reply carried more than ${MAX_CANCELLED} cancellations`);
dropped += over;
const seen = new Set();
for (const entry of raw.pickupsCancelled.slice(0, MAX_CANCELLED)) {
// Numbers, objects and nulls are not ids. Same shape as everywhere else
// — an id that would be refused as a booking cannot be honoured as a
// withdrawal either.
const id = typeof entry === 'string' ? entry.trim() : '';
if (!ID_RE.test(id) || seen.has(id)) {
dropped++;
continue;
}
seen.add(id);
cancelled.push(id);
}
}
}
const out = { pickups, cancelled, dropped };
if (problems.length) out.error = problems.join('; ');
return out;
}
/**
* Read at most REPLY_MAX_BYTES, and stop pulling the moment that is passed.
*
* Deliberately not res.text(): that reads whatever arrives and only then lets
* you measure it, which is the wrong order when the point of the measurement is
* to not have the thing in memory.
*/
async function readCapped(res) {
const stream = res.body;
if (stream && typeof stream.getReader === 'function') {
const reader = stream.getReader();
const chunks = [];
let total = 0;
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
total += value.length;
if (total > REPLY_MAX_BYTES) {
// Hang up rather than drain politely. Whatever is still coming is
// going to be discarded anyway.
await reader.cancel().catch(() => {});
return { tooBig: true };
}
chunks.push(value);
}
} finally {
try {
reader.releaseLock();
} catch {
// cancel() already released it.
}
}
return { text: Buffer.concat(chunks).toString('utf8') };
}
// No stream to meter: a response with no body, or a stand-in in a test.
if (typeof res.text === 'function') {
const text = await res.text();
return text.length > REPLY_MAX_BYTES ? { tooBig: true } : { text };
}
return { text: null };
}
/**
* The whole reply path: content type, size, JSON, fields.
*
* Content-Type has to say JSON before a single byte of body is pulled. A
* receiver answering an API call with an HTML error page is the everyday shape
* of a misconfigured proxy, and handing that to a parser wastes time at best.
* A reply with no content type at all is silence, not an error — that is what
* every receiver written before this feature existed sends back.
*/
async function readReply(res) {
const headers = res && res.headers;
const get = headers && typeof headers.get === 'function' ? (k) => headers.get(k) : () => null;
const ctype = get('content-type');
if (!ctype) return { pickups: [], dropped: 0 };
if (!/^application\/(json|[\w.+-]+\+json)\s*(;|$)/i.test(ctype.trim()))
return { pickups: [], dropped: 0, error: 'reply is ' + str(ctype.split(';')[0], 60) + ', not JSON' };
const declared = Number(get('content-length'));
// The cheapest guard there is: the receiver said how big it is, and it is too
// big, so nothing needs reading.
if (Number.isFinite(declared) && declared > REPLY_MAX_BYTES)
return { pickups: [], dropped: 0, error: `reply declared ${declared} bytes, over the ${REPLY_MAX_BYTES} limit` };
const body = await readCapped(res);
if (body.tooBig) return { pickups: [], dropped: 0, error: `reply exceeded ${REPLY_MAX_BYTES} bytes` };
return parseReply(body.text);
}
/**
* POST once, with retries. Resolves to {ok, status, attempts}; never throws —
* a feed that crashes the timer it runs on takes the whole feature down until
* the next restart.
*/
async function post(cfg, payload, deps) {
const doFetch = (deps && deps.fetch) || globalThis.fetch;
const sleep = (deps && deps.sleep) || ((ms) => new Promise((r) => setTimeout(r, ms)));
const body = JSON.stringify(payload);
let last = null;
for (let attempt = 1; attempt <= ATTEMPTS; attempt++) {
// A fresh timestamp per attempt: a retry after a 30 s backoff must not
// arrive already outside the receiver's replay window.
const timestamp = Math.floor(Date.now() / 1000);
const controller = new AbortController();
const abort = setTimeout(() => controller.abort(), cfg.timeoutMs);
try {
const res = await doFetch(cfg.url, {
method: 'POST',
// The receiver does not get to choose where the report actually lands.
// Without this, fetch defaults to `follow`: a 308 from the receiver
// re-sends the POST — body and X-Meistertracker-Signature intact, since
// only Authorization/Cookie are stripped on an origin change — to
// whatever host Location names, plain-HTTP hosts on the LAN included.
// checkUrl only ever runs when the config is saved, so a redirect
// target is a target nobody vetted. "Push-only, and to exactly the
// configured address" is the promise; following a hop breaks it.
redirect: 'manual',
headers: {
'Content-Type': 'application/json',
'User-Agent': 'meistertracker-harvest-feed/' + VERSION,
'X-Meistertracker-Timestamp': String(timestamp),
'X-Meistertracker-Signature': sign(cfg.secret, timestamp, body)
},
body,
signal: controller.signal
});
if (res.ok) {
const out = { ok: true, status: res.status, attempts: attempt };
// Rule 1 up in "The reply", made concrete: the payload is delivered the
// moment the receiver says 2xx, so reading what it sent back gets its
// own try/catch and there is no path from in here to ok:false.
try {
out.reply = await readReply(res);
} catch (e) {
out.reply = { pickups: [], dropped: 0, error: e.message };
}
return out;
}
// With redirect:'manual' a 3xx arrives here instead of being chased.
// Retrying cannot help — the receiver is pointing somewhere else and will
// keep doing so — and each retry is another copy of the payload aimed at
// an unvetted URL. Stop, and say what to fix.
if (res.status >= 300 && res.status < 400) {
return {
ok: false,
error: 'HTTP ' + res.status + ' redirect refused — set the feed URL to the receiver’s final address',
attempts: attempt
};
}
last = 'HTTP ' + res.status;
// 4xx means the receiver understood us and said no — a bad secret, a
// wrong path, a payload it rejects. Retrying hammers it without ever
// changing the outcome. 408 and 429 are the two that do pass with time.
if (res.status >= 400 && res.status < 500 && res.status !== 408 && res.status !== 429)
return { ok: false, error: last, attempts: attempt };
} catch (e) {
last = e.name === 'AbortError' ? `timeout after ${cfg.timeoutMs} ms` : e.message;
} finally {
clearTimeout(abort);
}
if (attempt < ATTEMPTS) await sleep(attempt * 5000);
}
return { ok: false, error: last, attempts: ATTEMPTS };
}
/** Build and send once. Returns the same shape as post(), plus the payload. */
async function sendOnce(database, cfg, deps) {
const payload = buildPayload(database, cfg, deps && deps.now);
const result = await post(cfg, payload, deps);
return { ...result, payload };
}
/**
* Write the outcome where Settings can show it.
*
* A log line only helps someone who already suspects a problem; "last delivery:
* 3 days ago" on the screen is what makes a feed that quietly stopped visible
* at all. Recording it must never take the caller down with it.
*/
function note(database, dbApi, ok, error) {
if (!database || !dbApi || typeof dbApi.updateHarvestFeedStatus !== 'function') return;
try {
dbApi.updateHarvestFeedStatus(database, { at: new Date().toISOString(), ok, error });
} catch {
// Bookkeeping around a push that already happened.
}
}
/**
* Take delivery of whatever came back, and confirm what went out.
*
* Order matters and only one order is right. Confirming comes first, and covers
* exactly the ids a *successful* push carried: those the receiver has now
* definitely heard about. Anything arriving after that genuinely is a repeat,
* and re-arms itself in storePickup().
*
* Confirming what a failed push carried would be the bug this ordering exists
* to prevent — the receiver never heard, drops the pickup from its next reply
* anyway, and the two sides quietly disagree with nothing left to reconcile
* them.
*
* Best-effort throughout, like note(): the push has happened, and none of this
* may throw its way out to the timer.
*/
function receive(database, dbApi, log, r) {
if (!database || !dbApi || typeof dbApi.storePickup !== 'function') return;
try {
if (r.ok && Array.isArray(r.payload.pickupsDone) && r.payload.pickupsDone.length)
dbApi.ackPickups(database, r.payload.pickupsDone);
const reply = r.reply;
if (!reply) return;
if (reply.error) log('warn', 'Harvest feed reply not usable', { error: reply.error, dropped: reply.dropped });
let fresh = 0;
for (const p of reply.pickups) {
// One unusable row must not cost the other nineteen. A full table throws
// here, which is the one case worth a line of its own.
try {
if (dbApi.storePickup(database, p) !== 'unchanged') fresh++;
} catch (e) {
log('warn', 'Pickup rejected', { id: p.id, error: e.message });
}
}
// Withdrawals last, and that ordering is a decision rather than an
// accident. Should one reply somehow name the same id in both lists, the
// withdrawal is the statement that has to survive: packing a crate for a
// cancelled order costs produce, and not packing one for an order that is
// still live costs a phone call.
//
// Applied to the database before it can appear in `pickupsDone` — same
// ordering as a booking, same reason. Confirming a withdrawal this end has
// not carried out means the receiver stops repeating it while the pickup is
// still sitting in the list.
let withdrawn = { removed: 0, recorded: 0, skipped: 0 };
if (reply.cancelled && reply.cancelled.length && typeof dbApi.cancelPickups === 'function') {
try {
withdrawn = dbApi.cancelPickups(database, reply.cancelled);
} catch (e) {
log('warn', 'Pickup withdrawals not applied', { error: e.message });
}
}
// Long-finished collections go, so the table cannot grow until storePickup
// starts refusing new ones. Here rather than on a timer of its own: this
// runs on every push, it is already best-effort, and a pruning pass that
// needs its own schedule is one that stops running when the feature is off.
if (typeof dbApi.prunePickups === 'function') {
try {
const weg = dbApi.prunePickups(database);
if (weg) log('info', 'Old pickups pruned', { removed: weg });
} catch (e) {
log('warn', 'Pickup pruning skipped', { error: e.message });
}
}
// Silence when nothing moved: this runs every quarter of an hour and a
// receiver repeats its open pickups every single time. A withdrawal that
// removed nothing is the same non-event — the id was never here, which the
// receiver cannot know and reports anyway.
if (fresh || withdrawn.removed)
log('info', 'Pickups received', {
stored: fresh,
of: reply.pickups.length,
withdrawn: withdrawn.removed
});
} catch (e) {
log('warn', 'Harvest feed reply not stored', { error: e.message });
}
}
/**
* One complete exchange: build, send, record the outcome, take delivery.
*
* The timer calls this and so do the tests, so the confirmation ordering that
* receive() describes is exercised by the same code path that runs in
* production rather than by a reimplementation of it.
*/
async function deliver({ database, cfg, dbApi, log, deps }) {
const r = await sendOnce(database, cfg, deps);
if (r.ok)
log('info', 'Harvest feed sent', {
harvested: r.payload.harvested.length,
planned: r.payload.planned.length,
attempts: r.attempts
});
else log('warn', 'Harvest feed failed', { error: r.error, attempts: r.attempts });
note(database, dbApi, r.ok, r.error);
receive(database, dbApi, log, r);
return r;
}
/**
* Start the timer. Safe to call when the feature is off (no URL) — it says so
* once and does nothing. Returns true when a timer is running.
*
* `skip` exists for the same reason the DuckDNS updater has one: a worktree or
* staging copy usually inherits the production .env, and two servers posting
* contradictory snapshots to one receiver is worse than one posting none.
*/
function start({ database, env, log, skip, dbApi, deps }) {
stop();
let cfg;
try {
cfg = resolveConfig({ database, env, dbApi });
} catch (e) {
log('error', 'Harvest feed misconfigured — not started', { error: e.message });
return false;
}
if (!cfg) return false;
if (skip) {
log('info', 'Harvest feed skipped', { reason: 'worktree mode' });
return false;
}
const tick = async () => {
if (inFlight) {
log('warn', 'Harvest feed still sending — tick skipped');
return;
}
inFlight = true;
try {
await deliver({ database, cfg, dbApi, log, deps });
} catch (e) {
// buildPayload can throw if the schema is mid-migration. Log, keep the
// timer.
log('error', 'Harvest feed error', { error: e.message });
note(database, dbApi, false, e.message);
} finally {
inFlight = false;
}
};
timer = setInterval(tick, cfg.intervalMs);
if (timer.unref) timer.unref();
// The URL is logged, the secret never is.
log('info', 'Harvest feed started', { url: cfg.url, everyMin: cfg.intervalMs / 60000 });
tick();
return true;
}
function stop() {
if (timer) clearInterval(timer);
timer = null;
}
/**
* Push once, right now, without waiting for the timer.
*
* For the moment a release changes: the shop should not go on offering an
* amount somebody has just corrected. Everything else about it is the ordinary
* push.
*
* ⚠️ **Through the same `inFlight` guard as the timer, and that is the whole
* reason this function exists** rather than a `deliver()` call at the call
* site. It was one at first, and it bypassed the guard: two pushes could then
* be in the air together, and the receiver's replay protection rejects the
* second of two requests that share a unix-second timestamp. That 409 is
* recorded by note() as a failed push — so a *successful* delivery would leave
* "the shop has not heard this" on the screen, which is the one thing that
* display exists to get right.
*
* Skipping is the correct outcome, not a loss: a push already in flight is
* carrying the same row, and the payload is a snapshot rather than an event
* log. Returns what happened so a caller can log it; never throws.
*/
async function pushNow({ database, env, log, dbApi, deps, skip }) {
if (skip) return 'skipped';
let cfg;
try {
cfg = resolveConfig({ database, env, dbApi });
} catch {
// Misconfigured. start() has already said so once; saying it again on every
// save would bury the line that matters.
return 'off';
}
if (!cfg) return 'off';
if (inFlight) return 'busy';
inFlight = true;
try {
await deliver({ database, cfg, dbApi, log, deps });
return 'sent';
} catch (e) {
log('warn', 'Immediate harvest feed push failed', { error: e.message });
note(database, dbApi, false, e.message);
return 'failed';
} finally {
inFlight = false;
}
}
/**
* May this harvest carry the release it claims? `null` if it may, a reason if not.
*
* Pure, and that is the point: the two guards here are the ones the field exists
* to protect — a slipped comma, and a release with no species to key it to — and
* this repo has no HTTP-level harness that could pin them inside a route handler.
* Inverting either comparison would otherwise stay green.
*
* `fatal` says which side of the harvest the answer falls on, because the two are
* not the same kind of wrong. More released than weighed is a bad request and the
* harvest never happened. A missing species is reported *next to* a stored
* harvest: a weighed harvest is a fact either way, and losing it to a quibble
* about the release would be the worse trade.
*/
function releaseProblem({ release, grams, species }) {
if (!(Number(release) > 0)) return null;
if (Number(release) > Number(grams)) return { reason: 'release must be <= grams', fatal: true };
if (!species) return { reason: 'species required to release', fatal: false };
return null;
}
module.exports = {
readConfig,
storedConfig,
resolveConfig,
buildPayload,
buildPickupWindows,
pickupWindowIndex,
windowId,
releaseProblem,
packSizes,
PACK_MIN_G,
PACK_MAX_G,
PACK_MAX_COUNT,
sign,
post,
sendOnce,
deliver,
parseReply,
readReply,
start,
stop,
pushNow,
VERSION,
VERSION_RELEASE,
REPLY_MAX_BYTES,
MAX_PICKUPS,
MAX_CANCELLED
};
// ── CLI ──────────────────────────────────────────────────────────────────────
// `node harvest-feed.js --dry-run` prints exactly what would be posted, without
// posting it. That is the answer to "is this thing going to leak something?" —
// look at it before you turn it on.
//
// node harvest-feed.js --dry-run # print the payload, send nothing
// node harvest-feed.js --once # build, sign, POST, print the result
// node harvest-feed.js --dry-run --db /path/to/other.db
if (require.main === module) {
const path = require('path');
const fs = require('fs');
const db = require('./db.js');
const envPath = path.join(__dirname, '.env');
if (fs.existsSync(envPath)) {
for (const line of fs.readFileSync(envPath, 'utf8').split('\n')) {
const m = line.match(/^\s*([^#=]+?)\s*=\s*(.*?)\s*$/);
if (!m) continue;
let v = m[2];
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
if (!process.env[m[1]]) process.env[m[1]] = v;
}
}
const dry = process.argv.includes('--dry-run');
if (!dry && !process.argv.includes('--once')) {
console.error('usage: node harvest-feed.js [--dry-run | --once] [--db <path>]');
process.exit(2);
}
// `--db` so a dry run can be aimed at a copy — reviewing what would leave the
// building is exactly the moment you do not want to touch the live file.
const dbFlag = process.argv.indexOf('--db');
const dbPath =
dbFlag !== -1 && process.argv[dbFlag + 1] ? process.argv[dbFlag + 1] : path.join(__dirname, 'meistertracker.db');
let cfg;
try {
cfg = readConfig(process.env);
} catch (e) {
console.error(e.message);
process.exit(2);
}
if (!cfg) {
// A dry run should work before anything is configured — that is when you
// most want to see the payload.
if (!dry) {
console.error('HARVEST_WEBHOOK_URL is not set — nothing to send.');
process.exit(2);
}
cfg = { freshDays: 3, plannedDays: 28, leadDays: 0, strain: true, site: '' };
}
const database = db.openDb(dbPath);
if (dry) {
console.log(JSON.stringify(buildPayload(database, cfg), null, 2));
process.exit(0);
}
sendOnce(database, cfg).then((r) => {
console.log(JSON.stringify(r.payload, null, 2));
console.log(r.ok ? `\nOK (HTTP ${r.status}, ${r.attempts} attempt(s))` : `\nFAILED: ${r.error}`);
process.exit(r.ok ? 0 : 1);
});
}