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 +4 -4
- package/package.json +3 -3
- package/plugin/dist/ais.js +1 -1
- package/plugin/dist/charts.js +14 -8
- package/plugin/dist/config.js +14 -4
- package/plugin/dist/index.js +37 -9
- package/plugin/dist/metrics.js +2 -2
- package/plugin/dist/pairing.js +133 -35
- package/plugin/dist/query.js +1 -1
- package/plugin/dist/rest.js +5 -5
- package/plugin/dist/rollup.js +1 -1
- package/plugin/dist/store.js +3 -3
- package/plugin/dist/uplink.js +138 -0
- package/plugin/dist/voyage.js +4 -4
- package/plugin/dist/voyagelog.js +3 -3
- package/public/assets/Logbook-Bf6IAjhz.js +1 -0
- package/public/assets/Map-D_WYyHPC.js +105 -0
- package/public/assets/Voyage-TvNQPRR8.js +1 -0
- package/public/assets/index-BGstqOHG.css +1 -0
- package/public/assets/{index-ukqYc_b7.js → index-DY-sE4Xu.js} +4 -4
- package/public/assets/style-DG0JTBaO.js +762 -0
- package/public/index.html +2 -2
- package/public/assets/Logbook-CREU8sGe.js +0 -1
- package/public/assets/Map-04Cjpqvt.js +0 -104
- package/public/assets/Voyage-Xsr6a6DP.js +0 -1
- package/public/assets/index-BaRFPcmb.css +0 -1
- package/public/assets/style-xd60AbLN.js +0 -762
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
4
|
-
"description": "
|
|
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
|
+
}
|
package/plugin/dist/ais.js
CHANGED
|
@@ -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
|
|
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) {
|
package/plugin/dist/charts.js
CHANGED
|
@@ -40,24 +40,29 @@ exports.chartContentType = chartContentType;
|
|
|
40
40
|
/**
|
|
41
41
|
* Chart asset resolution + safe file serving.
|
|
42
42
|
*
|
|
43
|
-
* The webapp map loads
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
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
|
|
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` :
|
|
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`
|
package/plugin/dist/config.js
CHANGED
|
@@ -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
|
|
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
|
|
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: '
|
|
124
|
-
description: 'Base URL the map loads
|
|
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)',
|
package/plugin/dist/index.js
CHANGED
|
@@ -33,14 +33,14 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
/**
|
|
36
|
-
* Siparu
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
270
|
-
// of how "never writes" is proved
|
|
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
|
}
|
package/plugin/dist/metrics.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
}
|
package/plugin/dist/pairing.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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: {
|
|
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
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
/**
|
|
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', {
|
|
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
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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;
|
package/plugin/dist/query.js
CHANGED
|
@@ -100,7 +100,7 @@ class QueryService {
|
|
|
100
100
|
}
|
|
101
101
|
return out;
|
|
102
102
|
}
|
|
103
|
-
/** Rows recorded today (UTC)
|
|
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));
|
package/plugin/dist/rest.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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)
|
package/plugin/dist/rollup.js
CHANGED
|
@@ -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
|
|
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.
|