signalk-siparu 0.2.2 → 0.2.3

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.
Files changed (37) hide show
  1. package/README.md +22 -24
  2. package/package.json +1 -1
  3. package/plugin/dist/anchor.d.ts +11 -0
  4. package/plugin/dist/anchor.js +103 -0
  5. package/plugin/dist/approval.d.ts +40 -0
  6. package/plugin/dist/approval.js +170 -0
  7. package/plugin/dist/config-routes.d.ts +1 -0
  8. package/plugin/dist/config-routes.js +8 -2
  9. package/plugin/dist/config.d.ts +8 -1
  10. package/plugin/dist/config.js +12 -5
  11. package/plugin/dist/contract.d.ts +16 -62
  12. package/plugin/dist/index.js +72 -52
  13. package/plugin/dist/keystore.js +1 -0
  14. package/plugin/dist/keysync.d.ts +1 -0
  15. package/plugin/dist/keysync.js +31 -5
  16. package/plugin/dist/live.d.ts +2 -7
  17. package/plugin/dist/live.js +2 -58
  18. package/plugin/dist/metrics.js +7 -1
  19. package/plugin/dist/pairing.d.ts +10 -0
  20. package/plugin/dist/pairing.js +49 -8
  21. package/plugin/dist/rollup.js +2 -1
  22. package/plugin/dist/sealer.d.ts +5 -8
  23. package/plugin/dist/sealer.js +9 -71
  24. package/plugin/dist/sealing.d.ts +3 -5
  25. package/plugin/dist/voyage-routes.d.ts +1 -0
  26. package/plugin/dist/voyage-routes.js +8 -2
  27. package/public/assets/{Logbook-DKQgrjzR.js → Logbook-CZb9OaA4.js} +1 -1
  28. package/public/assets/{Map-DeGKZhDf.js → Map-CN4rx5A-.js} +4 -4
  29. package/public/assets/Voyage-CzIqnUBA.js +5 -0
  30. package/public/assets/{index-DFYgRoHp.js → index-5gz9-vQd.js} +4 -4
  31. package/public/assets/{style-DhMV3_OD.js → style-CSszoHyM.js} +1 -1
  32. package/public/index.html +1 -1
  33. package/plugin/dist/alarms.d.ts +0 -23
  34. package/plugin/dist/alarms.js +0 -118
  35. package/plugin/dist/alertrules.d.ts +0 -74
  36. package/plugin/dist/alertrules.js +0 -282
  37. package/public/assets/Voyage-csfbROs0.js +0 -5
package/README.md CHANGED
@@ -47,16 +47,24 @@ generator tab. The same gauges are also served over the API (`GET /live`,
47
47
 
48
48
  Signal K ships with security switched off, and nothing in the setup makes you turn
49
49
  it on. With it off, every plugin's HTTP surface is open to anyone who can reach
50
- your boat's network, this one included: the pairing endpoints below answer whoever
51
- asks, so anyone on the network could pair their own screen to her. (The relay
52
- credential itself is out of reach: it lives in a mode-600 file under the plugin's
53
- data directory, not in the config.)
54
-
55
- Little of that is peculiar to Siparu. On an unsecured server `GET /skServer/plugins`
56
- already discloses every plugin's configuration and the App Store will install code.
57
- What is peculiar to Siparu is the consequence: someone on the same marina wifi can
58
- link your boat to **their** account, and your own screen will go on saying "paired"
59
- while they watch her.
50
+ your boat's network, this one included. (The relay credential itself is out of
51
+ reach: it lives in a mode-600 file under the plugin's data directory, not in the
52
+ config. The relay address is not a plugin option at all - it comes from the
53
+ server process's environment, so nothing that writes plugin options can redirect
54
+ the boat.)
55
+
56
+ Because of that, the plugin locks its own writes while security is off: pairing,
57
+ unpairing and log edits refuse until you either add an admin user in Signal K or
58
+ tick "Allow pairing and log edits while Signal K security is off" in the plugin
59
+ settings. The tick exists for installs that cannot easily turn security on; it is
60
+ an acceptance of the risk, not a removal of it.
61
+
62
+ The lock cannot make an unsecured server safe. On such a server
63
+ `GET /skServer/plugins` already discloses every plugin's configuration, the
64
+ server's own config route can change any plugin's settings - including that very
65
+ tick - and the App Store will install code. The consequence peculiar to Siparu is
66
+ what a stranger on the marina wifi could do with an open door: link your boat to
67
+ **their** account while your own screen goes on saying "paired".
60
68
 
61
69
  So before you pair, add an admin user in Signal K (Security > Users). It takes a
62
70
  minute and it is the difference between a boat you share and a boat you leak.
@@ -148,20 +156,10 @@ that sends a position in the clear, because a boat whose privacy depends on the
148
156
  state of a list held elsewhere does not have any.
149
157
 
150
158
  Once a device is named, the report is encrypted to it and the relay carries a
151
- block it cannot read. Three things stay legible beside it, and they are worth
152
- stating plainly because they are what a carrier can see:
153
-
154
- - Her identifier and the time she sent the frame, so it can be routed at all.
155
- - How loud she is: `normal`, `warning` or `alarm`, and nothing about what is
156
- wrong. A carrier has to know a notification is due, and this field is inside
157
- her signature so that it cannot decide one is not.
158
- - When the newest audible condition began, as a moment in time. Without it a
159
- second alarm raised while a first one stands is indistinguishable from the
160
- first one still standing, and the second would never be announced. It says
161
- that an event is a different event; what the event is stays encrypted.
162
-
163
- Taken together, a carrier can see that something is wrong aboard, roughly how
164
- serious, and when each one started. It cannot see what any of them is.
159
+ block it cannot read. Two things stay legible beside it, and they are worth
160
+ stating plainly because they are what a carrier can see: her identifier and
161
+ the time she sent the frame, so it can be routed at all. Both are inside her
162
+ signature, so neither can be rewritten in transit.
165
163
 
166
164
  Voyage detection opens after sustained movement, closes after sustained
167
165
  stillness, and folds short docking manoeuvres into the preceding voyage. Its
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "signalk-siparu",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
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 dashboard.",
5
5
  "keywords": [
6
6
  "signalk-node-server-plugin",
@@ -0,0 +1,11 @@
1
+ import type { DeviceAnchor } from './approval';
2
+ export declare class AnchorStore {
3
+ private readonly file;
4
+ private writeChain;
5
+ constructor(dataDir: string);
6
+ load(boatId: string | undefined, pairedAt: string | undefined): DeviceAnchor | null | 'broken';
7
+ set(boatId: string, pairedAt: string, anchor: DeviceAnchor): Promise<void>;
8
+ clear(): Promise<void>;
9
+ flush(): Promise<void>;
10
+ private write;
11
+ }
@@ -0,0 +1,103 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.AnchorStore = void 0;
37
+ const fs = __importStar(require("fs"));
38
+ const path = __importStar(require("path"));
39
+ const FILE_VERSION = 1;
40
+ const PUB_SHAPE = /^[A-Za-z0-9_-]{43}$/;
41
+ class AnchorStore {
42
+ file;
43
+ writeChain = Promise.resolve();
44
+ constructor(dataDir) {
45
+ this.file = path.join(dataDir, 'anchor.json');
46
+ }
47
+ load(boatId, pairedAt) {
48
+ let text;
49
+ try {
50
+ text = fs.readFileSync(this.file, 'utf8');
51
+ }
52
+ catch (e) {
53
+ return e?.code === 'ENOENT' ? null : 'broken';
54
+ }
55
+ let raw;
56
+ try {
57
+ raw = JSON.parse(text);
58
+ }
59
+ catch {
60
+ return 'broken';
61
+ }
62
+ if (raw.v !== FILE_VERSION ||
63
+ typeof raw.boat !== 'string' ||
64
+ typeof raw.paired_at !== 'string' ||
65
+ typeof raw.kid !== 'string' ||
66
+ raw.kid.length === 0 ||
67
+ typeof raw.pub !== 'string' ||
68
+ !PUB_SHAPE.test(raw.pub)) {
69
+ return 'broken';
70
+ }
71
+ if (!boatId || raw.boat !== boatId)
72
+ return null;
73
+ if (!pairedAt || raw.paired_at !== pairedAt)
74
+ return null;
75
+ return { kid: raw.kid, pub: raw.pub };
76
+ }
77
+ set(boatId, pairedAt, anchor) {
78
+ return this.write(async () => {
79
+ const tmp = `${this.file}.tmp`;
80
+ const shape = {
81
+ v: FILE_VERSION,
82
+ boat: boatId,
83
+ paired_at: pairedAt,
84
+ kid: anchor.kid,
85
+ pub: anchor.pub
86
+ };
87
+ await fs.promises.writeFile(tmp, JSON.stringify(shape, null, 2), { mode: 0o600 });
88
+ await fs.promises.chmod(tmp, 0o600);
89
+ await fs.promises.rename(tmp, this.file);
90
+ });
91
+ }
92
+ clear() {
93
+ return this.write(() => fs.promises.rm(this.file, { force: true }));
94
+ }
95
+ flush() {
96
+ return this.writeChain.catch(() => undefined);
97
+ }
98
+ write(run) {
99
+ this.writeChain = this.writeChain.then(run, run);
100
+ return this.writeChain;
101
+ }
102
+ }
103
+ exports.AnchorStore = AnchorStore;
@@ -0,0 +1,40 @@
1
+ import { type KeyObject } from 'crypto';
2
+ import type { DevicePublicKey } from './contract';
3
+ export declare const MAC_BYTES = 32;
4
+ export interface DeviceAnchor {
5
+ kid: string;
6
+ pub: string;
7
+ }
8
+ export declare function approvalInput(boat: string, approverKid: string, subjectKid: string, subjectPub: Buffer): Buffer;
9
+ export declare function computeApproval(opts: {
10
+ approverPriv: KeyObject;
11
+ inboxPub: Buffer;
12
+ boat: string;
13
+ approverKid: string;
14
+ subjectKid: string;
15
+ subjectPub: Buffer;
16
+ }): Buffer;
17
+ export declare function verifyApproval(opts: {
18
+ inboxPriv: KeyObject;
19
+ approverPub: Buffer;
20
+ boat: string;
21
+ approverKid: string;
22
+ subjectKid: string;
23
+ subjectPub: Buffer;
24
+ mac: Buffer;
25
+ }): boolean;
26
+ export interface SkippedDevice {
27
+ kid: string;
28
+ reason: string;
29
+ }
30
+ export interface AcceptResult {
31
+ accepted: DevicePublicKey[];
32
+ skipped: SkippedDevice[];
33
+ pinned: boolean;
34
+ }
35
+ export declare function acceptDevices(opts: {
36
+ anchor: DeviceAnchor | null | 'broken';
37
+ entries: DevicePublicKey[];
38
+ inboxPriv: KeyObject | undefined;
39
+ boat: string | undefined;
40
+ }): AcceptResult;
@@ -0,0 +1,170 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.MAC_BYTES = void 0;
4
+ exports.approvalInput = approvalInput;
5
+ exports.computeApproval = computeApproval;
6
+ exports.verifyApproval = verifyApproval;
7
+ exports.acceptDevices = acceptDevices;
8
+ const crypto_1 = require("crypto");
9
+ const sealing_1 = require("./sealing");
10
+ const LABEL = 'siparu/device-approval/v1';
11
+ const APPROVAL_VERSION = 1;
12
+ const KEY_BYTES = 32;
13
+ exports.MAC_BYTES = 32;
14
+ function approvalInput(boat, approverKid, subjectKid, subjectPub) {
15
+ return Buffer.concat([
16
+ Buffer.from(LABEL, 'utf8'),
17
+ (0, sealing_1.u16be)(APPROVAL_VERSION),
18
+ (0, sealing_1.lp)(Buffer.from(boat, 'utf8')),
19
+ (0, sealing_1.lp)(Buffer.from(approverKid, 'utf8')),
20
+ (0, sealing_1.lp)(Buffer.from(subjectKid, 'utf8')),
21
+ (0, sealing_1.lp)(subjectPub)
22
+ ]);
23
+ }
24
+ function approvalKey(shared, approverPub, boat, approverKid, subjectKid) {
25
+ const info = `${LABEL}/${boat}/${approverKid}/${subjectKid}`;
26
+ return Buffer.from((0, crypto_1.hkdfSync)('sha256', shared, approverPub, info, KEY_BYTES));
27
+ }
28
+ function computeApproval(opts) {
29
+ const shared = (0, crypto_1.diffieHellman)({
30
+ privateKey: opts.approverPriv,
31
+ publicKey: (0, sealing_1.x25519PublicFromRaw)(opts.inboxPub)
32
+ });
33
+ const approverPub = (0, sealing_1.publicFromPrivate)(opts.approverPriv);
34
+ const key = approvalKey(shared, approverPub, opts.boat, opts.approverKid, opts.subjectKid);
35
+ return (0, crypto_1.createHmac)('sha256', key)
36
+ .update(approvalInput(opts.boat, opts.approverKid, opts.subjectKid, opts.subjectPub))
37
+ .digest();
38
+ }
39
+ function verifyApproval(opts) {
40
+ if (opts.mac.length !== exports.MAC_BYTES)
41
+ return false;
42
+ let expected;
43
+ try {
44
+ const shared = (0, crypto_1.diffieHellman)({
45
+ privateKey: opts.inboxPriv,
46
+ publicKey: (0, sealing_1.x25519PublicFromRaw)(opts.approverPub)
47
+ });
48
+ const key = approvalKey(shared, opts.approverPub, opts.boat, opts.approverKid, opts.subjectKid);
49
+ expected = (0, crypto_1.createHmac)('sha256', key)
50
+ .update(approvalInput(opts.boat, opts.approverKid, opts.subjectKid, opts.subjectPub))
51
+ .digest();
52
+ }
53
+ catch {
54
+ return false;
55
+ }
56
+ return (0, crypto_1.timingSafeEqual)(expected, opts.mac);
57
+ }
58
+ function acceptDevices(opts) {
59
+ const { anchor, entries, inboxPriv, boat } = opts;
60
+ if (anchor === null) {
61
+ return { accepted: [...entries], skipped: [], pinned: false };
62
+ }
63
+ const skipped = [];
64
+ if (anchor === 'broken' || !boat) {
65
+ return {
66
+ accepted: [],
67
+ skipped: entries.map((e) => ({
68
+ kid: e.kid,
69
+ reason: anchor === 'broken'
70
+ ? 'the anchor on disk cannot be read; re-pair to pin the screens again'
71
+ : 'the boat is not paired'
72
+ })),
73
+ pinned: true
74
+ };
75
+ }
76
+ const trusted = new Map();
77
+ const anchorPub = decodePub(anchor.pub);
78
+ if (anchorPub)
79
+ trusted.set(anchor.kid, anchorPub);
80
+ const accepted = [];
81
+ const pending = new Map();
82
+ for (const entry of entries) {
83
+ if (entry.kid === anchor.kid) {
84
+ const entryPub = decodePub(entry.pub);
85
+ if (anchorPub && entryPub?.equals(anchorPub)) {
86
+ accepted.push(entry);
87
+ }
88
+ else {
89
+ skipped.push({
90
+ kid: entry.kid,
91
+ reason: 'carries the anchor id over a different key'
92
+ });
93
+ }
94
+ continue;
95
+ }
96
+ pending.set(entry, '');
97
+ }
98
+ let grew = true;
99
+ while (grew) {
100
+ grew = false;
101
+ for (const [entry] of pending) {
102
+ const verdict = admissible(entry, trusted, inboxPriv, boat);
103
+ if (verdict === true) {
104
+ accepted.push(entry);
105
+ const pub = decodePub(entry.pub);
106
+ if (pub)
107
+ trusted.set(entry.kid, pub);
108
+ pending.delete(entry);
109
+ grew = true;
110
+ }
111
+ else {
112
+ pending.set(entry, verdict);
113
+ }
114
+ }
115
+ }
116
+ for (const [entry, reason] of pending) {
117
+ skipped.push({ kid: entry.kid, reason });
118
+ }
119
+ return { accepted, skipped, pinned: true };
120
+ }
121
+ function admissible(entry, trusted, inboxPriv, boat) {
122
+ const approvals = entry.approvals ?? [];
123
+ if (approvals.length === 0) {
124
+ return 'carries no approval from a device this boat trusts';
125
+ }
126
+ if (!inboxPriv) {
127
+ return 'the boat holds no inbox key yet to verify the approval with';
128
+ }
129
+ const subjectPub = decodePub(entry.pub);
130
+ const refusals = [];
131
+ for (const { by, mac } of approvals) {
132
+ const approverPub = trusted.get(by);
133
+ if (!approverPub) {
134
+ refusals.push({ rank: 2, text: `approved by "${by}", which this boat does not trust` });
135
+ continue;
136
+ }
137
+ const raw = decodeMac(mac);
138
+ if (!subjectPub || !raw) {
139
+ refusals.push({ rank: 1, text: 'the approval is malformed' });
140
+ continue;
141
+ }
142
+ const ok = verifyApproval({
143
+ inboxPriv,
144
+ approverPub,
145
+ boat,
146
+ approverKid: by,
147
+ subjectKid: entry.kid,
148
+ subjectPub,
149
+ mac: raw
150
+ });
151
+ if (ok)
152
+ return true;
153
+ refusals.push({ rank: 0, text: 'the approval does not verify' });
154
+ }
155
+ return worst(refusals);
156
+ }
157
+ function worst(refusals) {
158
+ const first = refusals.reduce((best, r) => (r.rank < best.rank ? r : best));
159
+ return refusals.length === 1
160
+ ? first.text
161
+ : `${first.text} (${refusals.length} approvals, none accepted)`;
162
+ }
163
+ function decodePub(pub) {
164
+ const raw = Buffer.from(pub, 'base64url');
165
+ return raw.length === 32 ? raw : null;
166
+ }
167
+ function decodeMac(mac) {
168
+ const raw = Buffer.from(mac, 'base64url');
169
+ return raw.length === exports.MAC_BYTES ? raw : null;
170
+ }
@@ -2,6 +2,7 @@ import type { ServerAPI } from '@signalk/server-api';
2
2
  import type { IRouter } from 'express';
3
3
  interface ConfigDeps {
4
4
  app: ServerAPI;
5
+ acceptOpenNetwork: () => boolean;
5
6
  getConfig: () => object;
6
7
  fuelPathsView: () => {
7
8
  available: string[];
@@ -40,12 +40,18 @@ function readJsonBody(req) {
40
40
  });
41
41
  }
42
42
  function registerConfigRoutes(router, deps) {
43
- const { app, getConfig, fuelPathsView, restart } = deps;
43
+ const { app, acceptOpenNetwork, getConfig, fuelPathsView, restart } = deps;
44
44
  router.get('/config/fuel-paths', (_req, res) => {
45
45
  res.json(fuelPathsView());
46
46
  });
47
47
  router.post('/config/fuel-paths', (0, origin_guard_1.sameOrigin)((req, res) => {
48
- if (!(0, pairing_1.securityOff)(app, req) && !allowConfigure(app, req)) {
48
+ if ((0, pairing_1.securityOff)(app, req)) {
49
+ if ((0, pairing_1.writeLocked)(app, req, acceptOpenNetwork())) {
50
+ res.status(403).json({ error: 'security_off', message: pairing_1.WRITE_LOCKED_MESSAGE });
51
+ return;
52
+ }
53
+ }
54
+ else if (!allowConfigure(app, req)) {
49
55
  res.status(403).json({ error: 'admin_required' });
50
56
  return;
51
57
  }
@@ -21,10 +21,10 @@ export interface Options {
21
21
  maxStorageMB: number;
22
22
  chartsRemoteUrl: string;
23
23
  chartsBasemapUrl: string;
24
- relayUrl: string;
25
24
  ports: PortEntry[];
26
25
  voyage: VoyageOptions;
27
26
  fuelRatePaths: string[];
27
+ acceptOpenNetwork: boolean;
28
28
  }
29
29
  export declare const DEFAULTS: Options;
30
30
  export declare const INTERNAL: {
@@ -37,6 +37,7 @@ export declare const INTERNAL: {
37
37
  export declare const KN_TO_MS = 0.514444;
38
38
  export declare function isCalendarMonthDay(s: string): boolean;
39
39
  export declare function safeRelayUrl(raw: unknown): string | undefined;
40
+ export declare const RELAY_URL: string;
40
41
  export declare function resolveOptions(raw: unknown): Options;
41
42
  export declare const CONFIG_SCHEMA: {
42
43
  type: string;
@@ -165,6 +166,12 @@ export declare const CONFIG_SCHEMA: {
165
166
  };
166
167
  };
167
168
  };
169
+ acceptOpenNetwork: {
170
+ type: string;
171
+ title: string;
172
+ description: string;
173
+ default: boolean;
174
+ };
168
175
  fuelRatePaths: {
169
176
  type: string;
170
177
  title: string;
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.CONFIG_SCHEMA = exports.KN_TO_MS = exports.INTERNAL = exports.DEFAULTS = void 0;
3
+ exports.CONFIG_SCHEMA = exports.RELAY_URL = exports.KN_TO_MS = exports.INTERNAL = exports.DEFAULTS = void 0;
4
4
  exports.isCalendarMonthDay = isCalendarMonthDay;
5
5
  exports.safeRelayUrl = safeRelayUrl;
6
6
  exports.resolveOptions = resolveOptions;
@@ -12,7 +12,6 @@ exports.DEFAULTS = {
12
12
  maxStorageMB: 500,
13
13
  chartsRemoteUrl: 'https://tiles.siparu.app',
14
14
  chartsBasemapUrl: 'https://tiles.openfreemap.org/planet',
15
- relayUrl: 'https://relay.siparu.app',
16
15
  ports: [],
17
16
  voyage: {
18
17
  openKnots: 1.5,
@@ -23,7 +22,8 @@ exports.DEFAULTS = {
23
22
  mergeShortNm: 1.0,
24
23
  phaseMinMinutes: 10
25
24
  },
26
- fuelRatePaths: []
25
+ fuelRatePaths: [],
26
+ acceptOpenNetwork: false
27
27
  };
28
28
  exports.INTERNAL = {
29
29
  samplePeriodMs: 2000,
@@ -49,6 +49,7 @@ function safeRelayUrl(raw) {
49
49
  return undefined;
50
50
  return raw.replace(/\/+$/, '');
51
51
  }
52
+ exports.RELAY_URL = safeRelayUrl(process.env.SIPARU_RELAY_URL) ?? 'https://relay.siparu.app';
52
53
  function resolveOptions(raw) {
53
54
  const c = (raw ?? {});
54
55
  const v = (c.voyage ?? {});
@@ -65,7 +66,6 @@ function resolveOptions(raw) {
65
66
  chartsBasemapUrl: /^https?:\/\/\S+$/.test(c.chartsBasemapUrl ?? '')
66
67
  ? c.chartsBasemapUrl.replace(/\/+$/, '')
67
68
  : exports.DEFAULTS.chartsBasemapUrl,
68
- relayUrl: safeRelayUrl(c.relayUrl) ?? exports.DEFAULTS.relayUrl,
69
69
  ports: Array.isArray(c.ports)
70
70
  ? c.ports
71
71
  .filter((p) => !!p &&
@@ -89,7 +89,8 @@ function resolveOptions(raw) {
89
89
  },
90
90
  fuelRatePaths: Array.isArray(c.fuelRatePaths)
91
91
  ? c.fuelRatePaths.filter((p) => typeof p === 'string' && p.trim().length > 0).map((p) => p.trim())
92
- : exports.DEFAULTS.fuelRatePaths
92
+ : exports.DEFAULTS.fuelRatePaths,
93
+ acceptOpenNetwork: c.acceptOpenNetwork === true
93
94
  };
94
95
  }
95
96
  exports.CONFIG_SCHEMA = {
@@ -206,6 +207,12 @@ exports.CONFIG_SCHEMA = {
206
207
  }
207
208
  }
208
209
  },
210
+ acceptOpenNetwork: {
211
+ type: 'boolean',
212
+ title: 'Allow pairing and log edits while Signal K security is off',
213
+ description: 'With Signal K security off, anyone on the boat\'s network can pair this boat to their own account, unpair her or edit her log, so Siparu refuses all three until you answer for it here. It cannot protect this page: the server writes these settings itself, and on an unsecured server anyone can change them, this tick included. The real fix is an admin user in Signal K (Security > Users). Tick this only on a network you trust.',
214
+ default: exports.DEFAULTS.acceptOpenNetwork
215
+ },
209
216
  fuelRatePaths: {
210
217
  type: 'array',
211
218
  title: 'Fuel consumption source (advanced)',
@@ -1,3 +1,4 @@
1
+ export type DepthDatum = 'belowTransducer' | 'belowKeel' | 'belowSurface';
1
2
  export interface Snapshot {
2
3
  ts: number;
3
4
  lat: number | null;
@@ -18,6 +19,7 @@ export interface Snapshot {
18
19
  air_temp_k: number | null;
19
20
  air_pressure_pa: number | null;
20
21
  depth: number | null;
22
+ depth_datum?: DepthDatum | null;
21
23
  water_temp_k: number | null;
22
24
  gps_satellites: number | null;
23
25
  ais_class: string | null;
@@ -221,6 +223,15 @@ export interface HealthResult {
221
223
  mode: 'sealed' | 'blocked' | 'none';
222
224
  reason: string | null;
223
225
  screens: string[];
226
+ screens_pinned: boolean;
227
+ screens_skipped: {
228
+ kid: string;
229
+ reason: string;
230
+ }[];
231
+ screens_rejected: {
232
+ kid: string;
233
+ reason: string;
234
+ }[];
224
235
  };
225
236
  }
226
237
  export type PhaseKind = 'underway' | 'anchored' | 'moored' | 'stopped';
@@ -323,73 +334,16 @@ export interface SealedFrame {
323
334
  sig: string;
324
335
  [extension: string]: unknown;
325
336
  }
326
- export type AlertLevel = 'normal' | 'warning' | 'alarm';
327
- export interface StandingCondition {
328
- path: string;
329
- state: Exclude<AlertLevel, 'normal'>;
330
- message: string | null;
331
- since: number;
332
- }
333
- export interface AlertNote {
334
- path: string;
335
- state: Exclude<AlertLevel, 'normal'>;
336
- message: string | null;
337
- since: number;
338
- }
339
- export type AlertRing = 'warning' | 'alarm' | 'never';
340
- export interface AlertRule {
341
- path: string;
342
- ring_from: AlertRing;
343
- }
344
- export interface SetAlertRulesRequest {
345
- type: 'setalertrules';
346
- v: number;
347
- id: string;
348
- kid: string;
349
- ts: number;
350
- rules: AlertRule[];
351
- proof: string;
352
- }
353
- export interface AlertRulesResult {
354
- rules: AlertRule[];
355
- ts: number;
356
- }
357
- export type SetAlertRulesResponse = {
358
- type: 'setalertrules';
359
- id: string;
360
- result: AlertRulesResult;
361
- } | {
362
- type: 'setalertrules';
363
- id: string;
364
- error: {
365
- code: string;
366
- message: string;
367
- };
368
- };
369
- export interface AlertRulesRequest {
370
- type: 'alertrules';
371
- id: string;
372
- }
373
- export interface AlertRulesReadResult extends AlertRulesResult {
374
- known: string[];
375
- }
376
- export type AlertRulesResponse = {
377
- type: 'alertrules';
378
- id: string;
379
- result: AlertRulesReadResult;
380
- } | {
381
- type: 'alertrules';
382
- id: string;
383
- error: {
384
- code: string;
385
- message: string;
386
- };
387
- };
388
337
  export interface BoatPublicKeys {
389
338
  identity: string;
390
339
  inbox: string;
391
340
  }
341
+ export interface DeviceApproval {
342
+ by: string;
343
+ mac: string;
344
+ }
392
345
  export interface DevicePublicKey {
393
346
  kid: string;
394
347
  pub: string;
348
+ approvals?: DeviceApproval[];
395
349
  }