signalk-siparu 0.1.0 → 0.1.2

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/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # Siparu
2
2
 
3
3
  Kept aboard, proven ashore. A Signal K plugin that records your boat's bridge
4
- data — position, speed, heading, wind, depth — into an on-board history with
4
+ data - position, speed, heading, wind, depth - into an on-board history with
5
5
  voyage detection, and serves it through a built-in dashboard (PWA) and a
6
6
  read-only REST API.
7
7
 
@@ -9,10 +9,10 @@ read-only REST API.
9
9
 
10
10
  - **Read-only.** The plugin never writes to your boat: no deltas are emitted,
11
11
  no PUT handlers registered, no action handlers, no NMEA out. The CI proves it
12
- on every commit — grep this codebase and see for yourself.
12
+ on every commit - grep this codebase and see for yourself.
13
13
  - **Your data stays on board.** History is stored on the boat, in plain NDJSON
14
14
  files with hourly/daily summaries. Nothing leaves the vessel unless you pair
15
- it, and pairing takes a deliberate tap at the helm — after which the boat
15
+ it, and pairing takes a deliberate tap at the helm - after which the boat
16
16
  sends its current position and name to the portal you paired it with, and
17
17
  nothing else. Unpair and it stops. The history never leaves.
18
18
  - **Zero runtime dependencies, pure JavaScript.** No native modules, so it
@@ -41,7 +41,7 @@ moves the plugin's own state and never touches the vessel's:
41
41
  |---|---|
42
42
  | `GET /pair/status` | Current pairing state |
43
43
  | `POST /pair/start` | Ask the relay for a code to show at the helm |
44
- | `POST /pair/approve` · `POST /pair/deny` | Answer a claim — the tap at the helm |
44
+ | `POST /pair/approve` · `POST /pair/deny` | Answer a claim - the tap at the helm |
45
45
  | `POST /pair/reset` | Unpair |
46
46
 
47
47
  Voyage detection opens after sustained movement, closes after sustained
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "signalk-siparu",
3
- "version": "0.1.0",
4
- "description": "Siparu \u2014 kept aboard, proven ashore. An impartial, timestamped record of every voyage: position, wind, depth and logbook, written on the boat and readable from anywhere. Read-only Signal K plugin with a built-in PWA.",
3
+ "version": "0.1.2",
4
+ "description": "Kept aboard, proven ashore. An impartial, timestamped record of every voyage: position, wind, depth and logbook, written on the boat and readable from anywhere. A read-only Signal K plugin with a built-in PWA.",
5
5
  "keywords": [
6
6
  "signalk-node-server-plugin",
7
7
  "signalk-webapp"
@@ -44,4 +44,4 @@
44
44
  "typescript": "^5.9.0",
45
45
  "vitest": "^4.1.0"
46
46
  }
47
- }
47
+ }
@@ -37,7 +37,7 @@ function kn(ms) {
37
37
  }
38
38
  /**
39
39
  * AIS text (name/ship_type/nav_state) renders in map popups. XSS
40
- * defense-in-depth: strip markup characters server-side too — the frontend
40
+ * defense-in-depth: strip markup characters server-side too - the frontend
41
41
  * escapes as well, this is the second layer.
42
42
  */
43
43
  function cleanStr(v) {
@@ -40,24 +40,29 @@ exports.chartContentType = chartContentType;
40
40
  /**
41
41
  * Chart asset resolution + safe file serving.
42
42
  *
43
- * The webapp map loads four kinds of assets: a basemap PMTiles, an optional
44
- * seamark PMTiles, glyph PBFs and a sprite sheet. Each resolves to either a
45
- * local file under `<dataDir>/charts/` (offline charts, dropped in by the
46
- * user or a future download feature) or the configured remote tile server.
47
- * /map-config reports the resolved URLs; the webapp never guesses.
43
+ * The webapp map loads a basemap, an optional seamark PMTiles, glyph PBFs and a
44
+ * sprite sheet. Seamarks, glyphs and sprites resolve to either a local file under
45
+ * `<dataDir>/charts/` (offline charts, dropped in by the user) or the configured
46
+ * asset server. /map-config reports the resolved URLs; the webapp never guesses.
47
+ *
48
+ * The basemap is the odd one out, because its two sources speak different tile
49
+ * schemas: a local `basemap.pmtiles` is a Protomaps archive, while the hosted
50
+ * basemap is OpenMapTiles vector tiles behind a TileJSON. They cannot be swapped
51
+ * for one another, so they get separate fields and exactly one is ever set. Local
52
+ * wins: charts already on disk cost no bandwidth and survive a dead uplink.
48
53
  *
49
54
  * Serving stays GET-only and read-only like every other route.
50
55
  */
51
56
  const fs = __importStar(require("node:fs"));
52
57
  const path = __importStar(require("node:path"));
53
- /** URL prefix the server mounts our router on — used for local asset URLs. */
58
+ /** URL prefix the server mounts our router on - used for local asset URLs. */
54
59
  const MOUNT = '/plugins/siparu';
55
60
  function chartsDir(dataDir) {
56
61
  return path.join(dataDir, 'charts');
57
62
  }
58
63
  /** Resolve every map asset to a local or remote URL. Sync fs is fine here:
59
64
  * called once per /map-config request against at most four stat'ed paths. */
60
- function resolveMapConfig(dataDir, remoteBaseUrl) {
65
+ function resolveMapConfig(dataDir, remoteBaseUrl, basemapTilesUrl) {
61
66
  const dir = chartsDir(dataDir);
62
67
  const has = (rel) => {
63
68
  try {
@@ -80,7 +85,8 @@ function resolveMapConfig(dataDir, remoteBaseUrl) {
80
85
  const localFonts = hasDir('fonts');
81
86
  const localSprites = hasDir('sprites');
82
87
  return {
83
- basemap: localBasemap ? `${MOUNT}/charts/basemap.pmtiles` : `${remoteBaseUrl}/basemap.pmtiles`,
88
+ basemap: localBasemap ? `${MOUNT}/charts/basemap.pmtiles` : null,
89
+ basemapTiles: localBasemap ? null : basemapTilesUrl,
84
90
  seamark: localSeamark ? `${MOUNT}/charts/seamark.pmtiles` : `${remoteBaseUrl}/seamark.pmtiles`,
85
91
  glyphs: localFonts
86
92
  ? `${MOUNT}/charts/fonts/{fontstack}/{range}.pbf`
@@ -3,7 +3,7 @@
3
3
  * Plugin configuration: JSON Schema for the Signal K admin UI + resolved
4
4
  * runtime options with defaults.
5
5
  *
6
- * Day-1 rule: no boat-specific value may be hardcoded — SOG sanity
6
+ * Day-1 rule: no boat-specific value may be hardcoded - SOG sanity
7
7
  * threshold, TWS source fallback, season start, boat name, optional port
8
8
  * table and voyage/tender-merge thresholds are all configuration.
9
9
  */
@@ -17,6 +17,7 @@ exports.DEFAULTS = {
17
17
  seasonStart: '01-01',
18
18
  maxStorageMB: 500,
19
19
  chartsRemoteUrl: 'https://tiles.siparu.app',
20
+ chartsBasemapUrl: 'https://tiles.openfreemap.org/planet',
20
21
  relayUrl: 'https://relay.siparu.app',
21
22
  ports: [],
22
23
  voyage: {
@@ -52,11 +53,14 @@ function resolveOptions(raw) {
52
53
  chartsRemoteUrl: /^https?:\/\/\S+$/.test(c.chartsRemoteUrl ?? '')
53
54
  ? c.chartsRemoteUrl.replace(/\/+$/, '')
54
55
  : exports.DEFAULTS.chartsRemoteUrl,
56
+ chartsBasemapUrl: /^https?:\/\/\S+$/.test(c.chartsBasemapUrl ?? '')
57
+ ? c.chartsBasemapUrl.replace(/\/+$/, '')
58
+ : exports.DEFAULTS.chartsBasemapUrl,
55
59
  relayUrl: /^https?:\/\/\S+$/.test(c.relayUrl ?? '')
56
60
  ? c.relayUrl.replace(/\/+$/, '')
57
61
  : exports.DEFAULTS.relayUrl,
58
62
  // Round-trip only. The plugin wrote this; the user never types it, and a
59
- // half-formed one is worse than none — a boatToken without a boatId cannot
63
+ // half-formed one is worse than none - a boatToken without a boatId cannot
60
64
  // stream anywhere, it can only confuse the screen into saying "paired".
61
65
  remote: c.remote && typeof c.remote.boatId === 'string' && typeof c.remote.boatToken === 'string'
62
66
  ? {
@@ -120,10 +124,16 @@ exports.CONFIG_SCHEMA = {
120
124
  },
121
125
  chartsRemoteUrl: {
122
126
  type: 'string',
123
- title: 'Chart tile server (advanced)',
124
- description: 'Base URL the map loads chart tiles, fonts and sprites from. Files placed in the plugin\'s "charts" data folder are served locally instead (offline charts).',
127
+ title: 'Seamark and font server (advanced)',
128
+ description: 'Base URL the map loads seamarks, fonts and sprites from. Files placed in the plugin\'s "charts" data folder are served locally instead (offline charts).',
125
129
  default: exports.DEFAULTS.chartsRemoteUrl
126
130
  },
131
+ chartsBasemapUrl: {
132
+ type: 'string',
133
+ title: 'Basemap tile server (advanced)',
134
+ description: 'TileJSON URL for the coastline, land and place names, in OpenMapTiles schema. The default is a free, keyless, planet-wide host; any OpenMapTiles server will do. A basemap.pmtiles in the "charts" data folder overrides this and is used offline.',
135
+ default: exports.DEFAULTS.chartsBasemapUrl
136
+ },
127
137
  ports: {
128
138
  type: 'array',
129
139
  title: 'Named ports (optional)',
@@ -33,14 +33,14 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  /**
36
- * Siparu — Signal K plugin entry.
36
+ * Siparu - Signal K plugin entry.
37
37
  *
38
38
  * Read-only by contract: this plugin never emits deltas and never issues
39
39
  * PUT requests. It subscribes to self paths, records history to
40
40
  * NDJSON + materialized rollups, and serves a read-only REST API.
41
41
  *
42
42
  * Runtime dependencies: none. Types come from @signalk/server-api and
43
- * express as devDependencies — keep every import from them `import type`,
43
+ * express as devDependencies - keep every import from them `import type`,
44
44
  * a value import would crash on the boat (AppStore installs with
45
45
  * --ignore-scripts and without devDependencies).
46
46
  */
@@ -56,6 +56,7 @@ const rest_1 = require("./rest");
56
56
  const rollup_1 = require("./rollup");
57
57
  const store_1 = require("./store");
58
58
  const time_1 = require("./time");
59
+ const uplink_1 = require("./uplink");
59
60
  const voyagelog_1 = require("./voyagelog");
60
61
  const PLUGIN_ID = 'siparu';
61
62
  function packageVersion() {
@@ -74,6 +75,7 @@ module.exports = (app) => {
74
75
  let rollups = null;
75
76
  let query = null;
76
77
  let voyages = null;
78
+ let uplink = null;
77
79
  let timer = null;
78
80
  let unsubscribes = [];
79
81
  // Bumped on every start/stop; a stale async init aborts instead of leaking
@@ -112,7 +114,7 @@ module.exports = (app) => {
112
114
  }
113
115
  snapshotsToday++;
114
116
  const age = state.lastDeltaTs === null ? null : Math.round((now - state.lastDeltaTs) / 1000);
115
- app.setPluginStatus(`Recording — ${snapshotsToday} rows today${age === null ? ', no data yet' : age > 60 ? `, data age ${age}s` : ''}`);
117
+ app.setPluginStatus(`Recording - ${snapshotsToday} rows today${age === null ? ', no data yet' : age > 60 ? `, data age ${age}s` : ''}`);
116
118
  }
117
119
  async function health() {
118
120
  if (!state || !store || !rollups || !query)
@@ -132,7 +134,7 @@ module.exports = (app) => {
132
134
  diagnosis: state.diagnose(now, app.getSelfPath('electrical') != null),
133
135
  paths: state.pathAges(now),
134
136
  storage: {
135
- // Absolute data_dir deliberately omitted — it leaks the OS username
137
+ // Absolute data_dir deliberately omitted - it leaks the OS username
136
138
  // and FS layout to any reader (anonymous when SK security is off).
137
139
  raw_bytes: usage.bytes,
138
140
  cap_bytes: opts.maxStorageMB * 1024 * 1024,
@@ -155,7 +157,7 @@ module.exports = (app) => {
155
157
  const plugin = {
156
158
  id: PLUGIN_ID,
157
159
  name: 'Siparu',
158
- description: 'Kept aboard, proven ashore — records position, wind, depth and voyage history on the boat and serves a read-only dashboard. Never writes to the boat.',
160
+ description: 'Kept aboard, proven ashore: records position, wind, depth and voyage history on the boat and serves a read-only dashboard. Never writes to the boat.',
159
161
  schema: config_1.CONFIG_SCHEMA,
160
162
  start(config) {
161
163
  const gen = ++startGen;
@@ -228,10 +230,24 @@ module.exports = (app) => {
228
230
  rollupHours: async (from, to) => ({
229
231
  rows: await ru.readHourly(from, to)
230
232
  }),
231
- mapConfig: () => (0, charts_1.resolveMapConfig)(app.getDataDirPath(), opts.chartsRemoteUrl),
233
+ mapConfig: () => (0, charts_1.resolveMapConfig)(app.getDataDirPath(), opts.chartsRemoteUrl, opts.chartsBasemapUrl),
232
234
  dataDir: () => app.getDataDirPath()
233
235
  });
234
- app.setPluginStatus('Recording — waiting for first snapshot');
236
+ // Ashore. Only started once the vessel's own recording is up, because that is
237
+ // the order of the promises: her history is hers whether or not anyone ever
238
+ // pays for the remote half, and it must not wait on a network to begin.
239
+ //
240
+ // Started even when she is not paired - it sends nothing until she is, and it
241
+ // means pairing her mid-passage starts the feed without a restart.
242
+ const up = new uplink_1.Uplink({
243
+ relayUrl: opts.relayUrl,
244
+ getRemote: () => opts.remote,
245
+ frame: () => live(),
246
+ debug: (msg) => app.debug(msg)
247
+ });
248
+ uplink = up;
249
+ up.start();
250
+ app.setPluginStatus('Recording - waiting for first snapshot');
235
251
  })
236
252
  .catch((err) => {
237
253
  app.setPluginError(`start failed: ${err}`);
@@ -253,6 +269,7 @@ module.exports = (app) => {
253
269
  });
254
270
  unsubscribes = [];
255
271
  (0, rest_1.setRestDeps)(null);
272
+ uplink?.stop();
256
273
  if (voyages)
257
274
  await voyages.flush();
258
275
  if (store)
@@ -262,18 +279,25 @@ module.exports = (app) => {
262
279
  rollups = null;
263
280
  query = null;
264
281
  voyages = null;
282
+ uplink = null;
265
283
  app.setPluginStatus('Stopped');
266
284
  },
267
285
  registerWithRouter(router) {
268
286
  (0, rest_1.registerRoutes)(router);
269
- // Pairing keeps its own routes. rest.ts is GET-only on purpose — that is part
270
- // of how "never writes" is proved — and pairing needs POST. It writes nothing
287
+ // Pairing keeps its own routes. rest.ts is GET-only on purpose - that is part
288
+ // of how "never writes" is proved - and pairing needs POST. It writes nothing
271
289
  // to Signal K either way: it calls the relay outbound and saves the plugin's
272
290
  // own options.
273
291
  (0, pairing_1.registerPairRoutes)(router, {
274
292
  app,
275
293
  relayUrl: opts?.relayUrl ?? config_1.DEFAULTS.relayUrl,
276
294
  boatName: () => opts?.boatName || String(app.getSelfPath('name') ?? ''),
295
+ uplinkStatus: () => uplink?.status() ?? null,
296
+ // The vessel's MMSI or UUID urn. Typed as a plain string, but the server only
297
+ // assigns it when the boat has one or the other, so at runtime it can be
298
+ // undefined - which is fine, because nothing is authorised by it: it is reported
299
+ // for identification, never used to prove who she is.
300
+ vesselUrn: () => String(app.selfId ?? ''),
277
301
  getRemote: () => opts?.remote,
278
302
  saveRemote: async (remote) => {
279
303
  // savePluginOptions persists through plugin updates (the plan requires
@@ -284,6 +308,10 @@ module.exports = (app) => {
284
308
  app.savePluginOptions(next, (err) => err ? reject(err instanceof Error ? err : new Error(String(err))) : resolve());
285
309
  });
286
310
  opts = next;
311
+ // A new link is not answerable for the old one's failures: an unlink followed
312
+ // by a fresh pairing must not leave "rejected" on the screen of a boat that is
313
+ // now streaming perfectly well.
314
+ uplink?.reset();
287
315
  }
288
316
  });
289
317
  }
@@ -96,7 +96,7 @@ class MetricsState {
96
96
  bySource.set(key, { value: stored, ts, emaIntervalMs });
97
97
  this.lastDeltaTs = ts;
98
98
  // Gust max-hold: remember the highest true wind speed seen since the
99
- // last snapshot flush — but only samples from the currently winning
99
+ // last snapshot flush - but only samples from the currently winning
100
100
  // TWS path+source, so a stale second anemometer can't inflate gusts.
101
101
  if (TWS_SET.has(path)) {
102
102
  const active = this.resolveConcept(TWS_PATHS, ts);
@@ -244,7 +244,7 @@ class MetricsState {
244
244
  if (now - this.lastDeltaTs > config_1.INTERNAL.degradedAfterMs) {
245
245
  return {
246
246
  code: 'instruments-off',
247
- message: 'Instruments have been silent for a while — normal if the boat is shut down.',
247
+ message: 'Instruments have been silent for a while - normal if the boat is shut down.',
248
248
  since_ts: this.lastDeltaTs
249
249
  };
250
250
  }
@@ -1,29 +1,52 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.RelayRefused = void 0;
3
4
  exports.maskEmail = maskEmail;
4
5
  exports.registerPairRoutes = registerPairRoutes;
5
6
  exports.__resetPairingState = __resetPairingState;
6
7
  /**
7
8
  * The device_code lives in memory only. If Signal K restarts mid-pairing the code
8
- * is lost and the skipper asks for a new one — which is correct: a secret that
9
+ * is lost and the skipper asks for a new one - which is correct: a secret that
9
10
  * outlives the screen showing it is a secret waiting to be found in a config file.
10
11
  */
11
12
  let deviceCode = null;
12
13
  let userCode = null;
13
14
  let expiresAt = null;
14
15
  let lastError = null;
15
- async function relay(url, path, body) {
16
+ /** A relay that answered and said no. Distinct from one that never answered at all. */
17
+ class RelayRefused extends Error {
18
+ status;
19
+ code;
20
+ constructor(status,
21
+ /** The relay's machine-readable reason, when it gave one ("not_your_boat"). */
22
+ code, message) {
23
+ super(message);
24
+ this.status = status;
25
+ this.code = code;
26
+ this.name = 'RelayRefused';
27
+ }
28
+ }
29
+ exports.RelayRefused = RelayRefused;
30
+ /**
31
+ * `boatToken` is the boat proving she is who she says she is. The relay will only let
32
+ * a re-pairing land on an existing vessel when this is present and valid - an identity
33
+ * she merely asserts (her MMSI) is public information and proves nothing.
34
+ */
35
+ async function relay(url, path, body, boatToken) {
16
36
  let res;
17
37
  try {
18
38
  res = await fetch(`${url}${path}`, {
19
39
  method: 'POST',
20
- headers: { 'content-type': 'application/json' },
40
+ headers: {
41
+ 'content-type': 'application/json',
42
+ ...(boatToken ? { authorization: `Bearer ${boatToken}` } : {})
43
+ },
21
44
  body: JSON.stringify(body)
22
45
  });
23
46
  }
24
47
  catch (e) {
25
- // fetch() throws a bare "TypeError: fetch failed" and buries the actual reason —
26
- // DNS, TLS, a captive portal, a dead uplink — in .cause. On a boat that reason IS
48
+ // fetch() throws a bare "TypeError: fetch failed" and buries the actual reason -
49
+ // DNS, TLS, a captive portal, a dead uplink - in .cause. On a boat that reason IS
27
50
  // the diagnosis, and a log line that omits it sends the owner looking at the wrong
28
51
  // thing. Carry it up.
29
52
  const cause = e instanceof Error && e.cause ? `: ${String(e.cause)}` : '';
@@ -31,11 +54,24 @@ async function relay(url, path, body) {
31
54
  }
32
55
  if (!res.ok) {
33
56
  const text = await res.text().catch(() => '');
34
- throw new Error(`relay ${path} -> ${res.status} ${text.slice(0, 120)}`);
57
+ let code = null;
58
+ try {
59
+ code = JSON.parse(text).error ?? null;
60
+ }
61
+ catch {
62
+ // Not every refusal is JSON (a proxy, a captive portal, a 502 from the edge).
63
+ }
64
+ // A refusal is not an outage, and telling them apart is the whole point of the
65
+ // message the skipper reads next. The relay rejecting a request means the boat's
66
+ // uplink WORKS - sending them to look at DNS and captive portals is a wrong
67
+ // diagnosis dressed up as a helpful one.
68
+ throw new RelayRefused(res.status, code, `relay ${path} -> ${res.status} ${text.slice(0, 120)}`);
35
69
  }
36
70
  return (await res.json());
37
71
  }
38
- /** o***@gmail.com — enough for the owner to recognise themselves, not enough to harvest. */
72
+ /** The relay refused because the claimant does not own the boat she is already linked to. */
73
+ const NOT_YOUR_BOAT = 'That account does not own this boat. She is still linked as before.';
74
+ /** o***@gmail.com - enough for the owner to recognise themselves, not enough to harvest. */
39
75
  function maskEmail(email) {
40
76
  if (!email)
41
77
  return null;
@@ -45,25 +81,30 @@ function maskEmail(email) {
45
81
  return `${email[0]}***${email.slice(at)}`;
46
82
  }
47
83
  function registerPairRoutes(router, deps) {
48
- const { app, relayUrl, boatName, getRemote, saveRemote } = deps;
84
+ const { app, relayUrl, boatName, vesselUrn, getRemote, saveRemote, uplinkStatus } = deps;
85
+ const paired = (remote) => ({
86
+ state: 'paired',
87
+ boatId: remote.boatId,
88
+ email: remote.pairedEmail,
89
+ pairedAt: remote.pairedAt,
90
+ uplink: uplinkStatus() ?? undefined
91
+ });
49
92
  /** What the on-board screen renders. Safe to poll; never returns a secret. */
50
93
  router.get('/pair/status', (_req, res) => {
51
94
  void (async () => {
52
95
  const remote = getRemote();
53
- if (remote) {
54
- res.json({
55
- state: 'paired',
56
- boatId: remote.boatId,
57
- email: remote.pairedEmail,
58
- pairedAt: remote.pairedAt
59
- });
60
- return;
61
- }
62
- if (lastError) {
63
- res.json({ state: 'error', message: lastError });
64
- return;
65
- }
66
- if (!deviceCode || !userCode || !expiresAt) {
96
+ // A pairing in progress outranks "paired": an already-linked boat can be
97
+ // re-paired, and during that the screen must show the new code. Reporting her as
98
+ // paired would hide the very thing the skipper is standing there to read.
99
+ if (deviceCode === null || userCode === null || expiresAt === null) {
100
+ if (remote) {
101
+ res.json(paired(remote));
102
+ return;
103
+ }
104
+ if (lastError) {
105
+ res.json({ state: 'error', message: lastError });
106
+ return;
107
+ }
67
108
  res.json({ state: 'idle' });
68
109
  return;
69
110
  }
@@ -101,26 +142,40 @@ function registerPairRoutes(router, deps) {
101
142
  }
102
143
  })();
103
144
  });
104
- /** "Turn on remote viewing" — asks the relay for a code to put on the screen. */
145
+ /**
146
+ * "Turn on remote viewing", and equally "re-pair her".
147
+ *
148
+ * An already-paired boat may start a new pairing without unlinking first, and she
149
+ * SHOULD: the token she is holding travels with this request, and it is the only
150
+ * thing that lets the relay put the new pairing back on the vessel that already
151
+ * exists. Making her unlink first would throw that proof away, and every reinstall
152
+ * would leave the owner another dead duplicate in her fleet.
153
+ */
105
154
  router.post('/pair/start', (_req, res) => {
106
155
  void (async () => {
107
- if (getRemote()) {
108
- res.status(409).json({ error: 'already_paired' });
109
- return;
110
- }
111
156
  lastError = null;
112
157
  try {
113
- const started = await relay(relayUrl, '/pair/start', { boat_name: boatName() || null });
158
+ const started = await relay(relayUrl, '/pair/start', {
159
+ boat_name: boatName() || null,
160
+ vessel_urn: vesselUrn() || null
161
+ }, getRemote()?.boatToken);
114
162
  deviceCode = started.device_code;
115
163
  userCode = started.user_code;
116
164
  expiresAt = new Date(Date.now() + started.expires_in * 1000).toISOString();
117
165
  res.json({ state: 'showing_code', userCode, expiresAt });
118
166
  }
119
167
  catch (e) {
120
- // No code is generated at all if the relay cannot be reached — the plan is
168
+ // No code is generated at all if the relay cannot be reached - the plan is
121
169
  // explicit about this. A code the owner can type but the boat can never
122
170
  // confirm is worse than an honest error.
123
- lastError = 'Cannot reach Siparu. Check the boat is online (DNS, port 443, captive portal).';
171
+ lastError =
172
+ e instanceof RelayRefused && e.status === 429
173
+ ? // The relay counts per IP, not per boat, and a boat on Starlink or marina
174
+ // wifi shares hers with everyone else behind the same CGNAT. Blaming the
175
+ // vessel for a neighbour's attempts would send the skipper hunting a fault
176
+ // that is not there.
177
+ 'Too many pairing attempts from this network. Wait an hour and try again.'
178
+ : 'Cannot reach Siparu. Check the boat is online (DNS, port 443, captive portal).';
124
179
  app.error(`pair start failed: ${String(e)}`);
125
180
  res.status(502).json({ state: 'error', message: lastError });
126
181
  }
@@ -137,9 +192,22 @@ function registerPairRoutes(router, deps) {
137
192
  return;
138
193
  }
139
194
  try {
195
+ const previous = getRemote();
140
196
  const done = await relay(relayUrl, '/pair/approve', { device_code: deviceCode });
197
+ // She is already linked, and this approval points somewhere else. That means the
198
+ // account which typed the code does not own her, so the relay opened a boat of
199
+ // ITS own. Adopting it would aim this vessel's feed at a stranger's account and
200
+ // leave the real owner's boat dark - the hijack, arriving by the back door, in a
201
+ // response the boat asked for. The relay refuses this too; the boat refuses it
202
+ // again, because a plugin that trusts whatever comes back has no defence at all.
203
+ if (previous && done.boat_id !== previous.boatId) {
204
+ deviceCode = userCode = expiresAt = null;
205
+ app.error(`pair approve returned a different boat (${done.boat_id}); refusing`);
206
+ res.status(409).json({ state: 'error', message: NOT_YOUR_BOAT });
207
+ return;
208
+ }
141
209
  // Persist BEFORE reporting success. If the disk is full (Cerbo issue #46 is
142
- // real and filed) this throws, and the owner is told pairing failed — rather
210
+ // real and filed) this throws, and the owner is told pairing failed - rather
143
211
  // than being told they are paired while the token quietly evaporates on the
144
212
  // next restart.
145
213
  await saveRemote({
@@ -148,14 +216,30 @@ function registerPairRoutes(router, deps) {
148
216
  pairedEmail: maskEmail(done.claimed_by_email),
149
217
  pairedAt: new Date().toISOString()
150
218
  });
219
+ // The new token is on disk, so the one it replaces may now be retired. This is
220
+ // the only reason the relay left the old token alive at all: had it killed the
221
+ // old one at approval and the write above failed, the boat would be off the air
222
+ // holding a token she never managed to keep. Best effort - if it does not get
223
+ // through, the previous token simply lives a little longer, which is harmless
224
+ // and self-corrects on the next re-pairing.
225
+ await relay(relayUrl, '/pair/confirm', {}, done.boat_token).catch((e) => {
226
+ app.debug(`pair confirm failed, old token still live: ${String(e)}`);
227
+ });
151
228
  deviceCode = userCode = expiresAt = null;
152
229
  res.json({ state: 'paired', boatId: done.boat_id });
153
230
  }
154
231
  catch (e) {
155
232
  app.error(`pair approve failed: ${String(e)}`);
156
- res.status(502).json({
233
+ // The relay refuses a re-pairing claimed by anyone who does not already own her,
234
+ // and that is not a fault to retry - it is an answer. Telling the skipper to try
235
+ // again would have them hammering a code that can never work, while the real
236
+ // cause (they typed it into the wrong account) goes unsaid.
237
+ const denied = e instanceof RelayRefused && e.code === 'not_your_boat';
238
+ if (denied)
239
+ deviceCode = userCode = expiresAt = null;
240
+ res.status(denied ? 409 : 502).json({
157
241
  state: 'error',
158
- message: 'Could not finish pairing. Try again.'
242
+ message: denied ? NOT_YOUR_BOAT : 'Could not finish pairing. Try again.'
159
243
  });
160
244
  }
161
245
  })();
@@ -166,7 +250,10 @@ function registerPairRoutes(router, deps) {
166
250
  await relay(relayUrl, '/pair/deny', { device_code: deviceCode }).catch(() => { });
167
251
  }
168
252
  deviceCode = userCode = expiresAt = null;
169
- res.json({ state: 'idle' });
253
+ // Refusing a re-pairing leaves the boat exactly as she was: still linked, still
254
+ // streaming, on the token she already holds. Only an unlink changes that.
255
+ const remote = getRemote();
256
+ res.json(remote ? paired(remote) : { state: 'idle' });
170
257
  })();
171
258
  });
172
259
  /**
@@ -174,11 +261,22 @@ function registerPairRoutes(router, deps) {
174
261
  *
175
262
  * This is the GDPR-critical one, and the plan says so: a boat changes hands, and
176
263
  * the previous owner must not keep watching her. Cutting from the vessel's own
177
- * screen — no portal, no account, just Signal K admin access — is the half of
264
+ * screen - no portal, no account, just Signal K admin access - is the half of
178
265
  * that which the new owner actually controls.
179
266
  */
180
267
  router.post('/pair/reset', (_req, res) => {
181
268
  void (async () => {
269
+ const remote = getRemote();
270
+ // Forgetting the token here is not enough - it would go on working at the relay,
271
+ // and anyone holding a copy (an old disk image, a plotter sold with the boat)
272
+ // could still write to this vessel. Tell the relay to kill it, THEN forget it.
273
+ // Best effort: if the boat is offline the local link is still cut, which is what
274
+ // the person standing at the screen asked for.
275
+ if (remote?.boatToken) {
276
+ await relay(relayUrl, '/pair/unlink', {}, remote.boatToken).catch((e) => {
277
+ app.error(`unlink could not reach the relay, token still live there: ${String(e)}`);
278
+ });
279
+ }
182
280
  await saveRemote(undefined);
183
281
  deviceCode = userCode = expiresAt = null;
184
282
  lastError = null;
@@ -100,7 +100,7 @@ class QueryService {
100
100
  }
101
101
  return out;
102
102
  }
103
- /** Rows recorded today (UTC) — used by /health. */
103
+ /** Rows recorded today (UTC) - used by /health. */
104
104
  async countToday(now) {
105
105
  const today = (0, time_1.dayKey)(now);
106
106
  const keys = (await this.store.listRawKeys()).filter((k) => k.startsWith(today));
@@ -38,7 +38,7 @@ exports.registerRoutes = registerRoutes;
38
38
  /**
39
39
  * Read-only REST API, mounted by the server at /plugins/siparu/.
40
40
  *
41
- * GET-only by construction — this file is the only place routes are
41
+ * GET-only by construction - this file is the only place routes are
42
42
  * registered, and it registers nothing but router.get(). "Never writes to
43
43
  * the boat" is a sales claim backed by grep.
44
44
  */
@@ -141,17 +141,17 @@ function registerRoutes(router) {
141
141
  return d.voyageTrack(id);
142
142
  });
143
143
  asyncGet('/ais/targets', (d, req) => d.aisTargets(intParam(req, 'max_nm'), intParam(req, 'max_age_min'), intParam(req, 'limit')));
144
- // Raw hourly rollup lines — the webapp derives history series (baro,
144
+ // Raw hourly rollup lines - the webapp derives history series (baro,
145
145
  // gust peaks, coarse tracks) from these instead of scanning raw files.
146
146
  asyncGet('/rollups/hourly', (d, req) => {
147
147
  const from = intParam(req, 'from') ?? 0;
148
148
  const to = intParam(req, 'to') ?? Date.now();
149
149
  return d.rollupHours(from, to);
150
150
  });
151
- // Resolved chart asset URLs (local charts folder vs remote tile server) —
151
+ // Resolved chart asset URLs (local charts folder vs remote tile server) -
152
152
  // the webapp asks instead of guessing, so offline charts "just work".
153
153
  asyncGet('/map-config', async (d) => d.mapConfig());
154
- // Local chart assets (PMTiles/glyphs/sprites) with HTTP Range support —
154
+ // Local chart assets (PMTiles/glyphs/sprites) with HTTP Range support -
155
155
  // express sendFile answers 206s, which is how PMTiles clients read.
156
156
  router.get('/charts/*', (req, res) => {
157
157
  if (!deps)
@@ -166,7 +166,7 @@ function registerRoutes(router) {
166
166
  if (explicitType)
167
167
  res.type(explicitType);
168
168
  // root + relative path: dotfile denial must only apply to segments
169
- // BELOW the charts dir — the data dir itself usually lives under a
169
+ // BELOW the charts dir - the data dir itself usually lives under a
170
170
  // dot directory (~/.signalk) and an absolute path would 403.
171
171
  res.sendFile(nodePath.relative(dir, abs), { root: dir, dotfiles: 'deny', cacheControl: true, maxAge: '1h' }, (err) => {
172
172
  if (!err || res.headersSent)
@@ -83,7 +83,7 @@ const LAST_ONLY_FIELDS = [
83
83
  ];
84
84
  /**
85
85
  * Clamp a query window to [epoch, now]. No data exists before 1970 or in the
86
- * future, so this narrows nothing legitimate — but it stops a hostile
86
+ * future, so this narrows nothing legitimate - but it stops a hostile
87
87
  * `?to=8640000000000000` from walking millions of month/year buckets and
88
88
  * freezing the single Node event loop the whole Signal K server shares.
89
89
  * Applied at the read sink so every caller (REST, voyage replay) is covered.