sailkick-boat 0.14.1 → 0.14.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.
package/README.md CHANGED
@@ -7,8 +7,8 @@
7
7
  > and a **local proxy that keeps the sailkick app and its charts/maps fully usable
8
8
  > offline** on board. The service runs at **[www.sailkick.io](https://www.sailkick.io)**.
9
9
  >
10
- > **Pairing needs an invite code** there is no open registration yet, so installing
11
- > this plugin from the Signal K Appstore is not enough on its own. Expect breaking
10
+ > **Registration is invite-only** and happens on the website, not in the plugin — so
11
+ > installing this from the Signal K Appstore is not enough on its own. Expect breaking
12
12
  > changes while the version is 0.x.
13
13
  >
14
14
  > **Want an invite, or more information?** Get in touch:
@@ -159,22 +159,36 @@ works **offline** with the boat's own data. Only when neither a local InfluxDB n
159
159
  telemetry is available do these paths **fall through to the cloud mirror**, so an
160
160
  online boat is never worse off than before.
161
161
 
162
- ## Setup: pair the boat once
163
- Fill the **Sailkick account** section — **invite code + boat name + password** — and
164
- save. The plugin calls the app's `POST /api/auth/signup` once: that creates your
165
- account, provisions its cloud storage, and returns the write token, bucket, org and
166
- InfluxDB URL for your boat. The bundle is cached (0600) in the plugin data dir and
167
- reused on every later start, so sync keeps working **offline** and across restarts.
162
+ ## Setup: register on the web, then paste the token
163
+ 1. Register your boat at **[www.sailkick.io](https://www.sailkick.io)** (an invite code
164
+ is needed — see above). The signup screen shows your boat's ingest credentials.
165
+ 2. **Copy the "Write token"** it is shown **once** and cannot be recovered. If you
166
+ lose it you need a new one minted.
167
+ 3. In Plugin Config, fill the **Sailkick account** section — **boat name + write
168
+ token** — and save.
168
169
 
169
- Pairing is **one-time by design** the invite code is single-use, so the plugin never
170
- calls signup twice. Once `account.json` exists the account fields are no longer read;
171
- delete that file to re-pair (which needs a fresh invite).
170
+ That is the whole handshake. The plugin resolves everything else locally (`bucket` =
171
+ `<slug>_raw`, `org` = `sailkick`) and never calls the app for configuration, so setup
172
+ works with no internet and there is nothing to re-fetch after a restart.
173
+
174
+ > **Ignore the "Influx URL", "Organization" and "Bucket" on that screen** — they are for
175
+ > the community `signalk-to-influxdb-v2` plugin. This plugin always writes to
176
+ > `https://sync.sailkick.io`; the endpoint is fleet-wide and cannot be set from the UI,
177
+ > so a wrong value can never redirect your telemetry. (Self-hosters: hand-edit
178
+ > `sync.influxUrl` in the plugin config JSON.) If the status line ever warns that sync is
179
+ > writing to a local address, telemetry is not leaving the boat.
180
+
181
+ Registration deliberately happens **only** in the web app. The plugin cannot sign up,
182
+ so there is no way to half-create an account or burn an invite from the boat.
183
+
184
+ > Changing the token later — after a rotation, say — is just editing the field and
185
+ > saving. Pasted values are trimmed, and the boat name is lower-cased for you.
172
186
 
173
187
  ## Config
174
188
  The page is deliberately small — everything else has a right answer and is a constant
175
189
  in `index.js`.
176
190
 
177
- - **Sailkick account**: `invite`, `slug` (boat name), `password`
191
+ - **Sailkick account**: `slug` (boat name), `writeToken`
178
192
  - **Telemetry sync → cloud**: `enabled`
179
193
  - **Offline app & maps**: `enabled`, `proxyPort` (default 8080), `localSignalkUrl`
180
194
  (default `http://127.0.0.1:3000`), `dataDir`, `seedEnabled`, `prefetchRadiusNm`,
package/index.js CHANGED
@@ -45,6 +45,17 @@ const HISTORY_TUNING = {
45
45
  ringSampleSec: 15
46
46
  }
47
47
 
48
+ // A *cloud* endpoint on loopback means telemetry never leaves the boat. On the wire it
49
+ // is indistinguishable from a normal offline backlog — the spool just grows — so it has
50
+ // to be called out explicitly. (The local history DB is legitimately on 127.0.0.1; this
51
+ // check is only ever applied to the sync path.)
52
+ function isLoopbackUrl (u) {
53
+ try {
54
+ const h = new URL(u).hostname.replace(/^\[|\]$/g, '')
55
+ return h === 'localhost' || h === '::1' || /^127\./.test(h)
56
+ } catch { return false }
57
+ }
58
+
48
59
  module.exports = function (app) {
49
60
  let sync = null
50
61
  let proxy = null
@@ -52,6 +63,7 @@ module.exports = function (app) {
52
63
  let history = null
53
64
  let statusTimer = null
54
65
  let accountStatus = null
66
+ let syncWarning = null
55
67
 
56
68
  const plugin = {
57
69
  id: 'sailkick-boat',
@@ -66,12 +78,11 @@ module.exports = function (app) {
66
78
  properties: {
67
79
  account: {
68
80
  type: 'object',
69
- title: 'Sailkick account (one-time pairing)',
70
- description: 'Pair this boat with your sailkick account once. The plugin creates the account, provisions its cloud storage, and stores the result locally — so it keeps syncing offline and after restarts. Your invite code is single-use; once paired these fields are no longer read.',
81
+ title: 'Sailkick account',
82
+ description: 'Register your boat at www.sailkick.io first the signup screen shows a write token. Paste it here together with your boat name. Keep that token safe: it is shown only once and cannot be recovered.',
71
83
  properties: {
72
- invite: { type: 'string', title: 'Invite code' },
73
- slug: { type: 'string', title: 'Boat name', description: '2–40 characters: lowercase letters, numbers, dashes. This is your permanent boat id.' },
74
- password: { type: 'string', title: 'Password', description: 'At least 8 characters.' }
84
+ slug: { type: 'string', title: 'Boat name', description: 'Exactly as registered on the website.' },
85
+ writeToken: { type: 'string', title: 'Write token', description: 'From the signup screen ("Write token"). Everything else — Influx URL, organization, bucket is derived from it and your boat name.' }
75
86
  }
76
87
  },
77
88
  sync: {
@@ -115,23 +126,17 @@ module.exports = function (app) {
115
126
 
116
127
  plugin.start = function (options) {
117
128
  const opts = options || {}
118
- // Pairing first: on the very first start this provisions the account and caches the
119
- // bundle; on every later start it is a disk read (see lib/account — signup is
120
- // single-use and must never run twice).
121
- ;(async () => {
122
- const r = await resolveAccountConfig(app, opts.account, { appUrl: SAILKICK_APP_URL })
123
- if (r.bundle) {
124
- accountStatus = r.source === 'paired'
125
- ? `account: paired as ${r.bundle.slug}`
126
- : `account: ${r.bundle.slug}`
127
- } else if (r.error) {
128
- accountStatus = `account: pairing failed — ${r.error}${r.terminal ? '' : ' (will retry on restart)'}`
129
- ;(app.error || console.error)('[sailkick-boat] pairing failed: ' + r.error)
130
- } else {
131
- accountStatus = 'account: not paired — enter your invite code'
132
- }
129
+ try {
130
+ // Purely local: the boat registers on the website, the owner pastes the write
131
+ // token here. Nothing to fetch, so nothing that can fail while offline.
132
+ const r = resolveAccountConfig(app, opts.account)
133
+ if (r.bundle) accountStatus = `account: ${r.bundle.slug}`
134
+ else if (r.error) accountStatus = `account: ${r.error}`
135
+ else accountStatus = 'account: not configured — register at www.sailkick.io, then paste your write token'
133
136
  startModules(opts, r.bundle)
134
- })().catch((e) => (app.error || console.error)('[sailkick-boat] start failed: ' + e.message))
137
+ } catch (e) {
138
+ (app.error || console.error)('[sailkick-boat] start failed: ' + e.message)
139
+ }
135
140
  }
136
141
 
137
142
  function startModules (opts, bundle) {
@@ -151,7 +156,12 @@ module.exports = function (app) {
151
156
  if (s.enabled !== false) {
152
157
  const syncOpts = {
153
158
  ...SYNC_TUNING,
154
- influxUrl: b.influxUrl || s.influxUrl || SAILKICK_INFLUX_URL,
159
+ // The cloud endpoint is fleet-wide and fixed. A pasted or cached bundle can
160
+ // never set it — the app's signup screen hands out its own http://localhost:8086,
161
+ // and a boat that believed it would spool to loopback forever, looking healthy.
162
+ // `sync.influxUrl` survives only as a self-hosting escape hatch: it is not in
163
+ // the schema, so it cannot arrive by pasting, only by editing the config JSON.
164
+ influxUrl: s.influxUrl || SAILKICK_INFLUX_URL,
155
165
  org: b.org || s.org || SYNC_TUNING.org,
156
166
  bucket: b.bucket || s.bucket,
157
167
  token: b.writeToken || s.token,
@@ -164,6 +174,10 @@ module.exports = function (app) {
164
174
  retryMinMs: s.retryMinMs || SYNC_TUNING.retryMinMs,
165
175
  retryMaxMs: s.retryMaxMs || SYNC_TUNING.retryMaxMs
166
176
  }
177
+ if (isLoopbackUrl(syncOpts.influxUrl)) {
178
+ syncWarning = `sync: ⚠ writing to ${syncOpts.influxUrl} — telemetry is NOT reaching the cloud`
179
+ ;(app.error || console.error)(`[sailkick-boat] sync target ${syncOpts.influxUrl} is a local address — telemetry will not reach the cloud`)
180
+ }
167
181
  try { sync = createSync(app, syncOpts); sync.start() } catch (e) {
168
182
  (app.error || console.error)('[sailkick-boat] sync start failed: ' + e.message)
169
183
  sync = null
@@ -255,6 +269,7 @@ module.exports = function (app) {
255
269
  if (!app.setPluginStatus) return
256
270
  const parts = []
257
271
  if (accountStatus) parts.push(accountStatus)
272
+ if (syncWarning) parts.push(syncWarning)
258
273
  if (sync) parts.push(sync.status())
259
274
  if (proxy) parts.push(proxy.status())
260
275
  if (telemetry) parts.push(telemetry.status())
@@ -274,6 +289,7 @@ module.exports = function (app) {
274
289
  history = null
275
290
  proxy = null
276
291
  accountStatus = null
292
+ syncWarning = null
277
293
  }
278
294
 
279
295
  // Mounted by Signal K at /plugins/sailkick-boat. Handlers dispatch to the live
@@ -1,113 +1,87 @@
1
1
  'use strict'
2
2
 
3
- // Sailkick account pairingONE TIME, then cached forever.
3
+ // Sailkick account configa pure local resolver, no network.
4
4
  //
5
- // The owner enters an invite code + boat name + password once. The plugin POSTs them to
6
- // the app's /api/auth/signup, which provisions the boat's InfluxDB bucket + write token
7
- // and returns an `ingest` bundle. That bundle is cached (0600) in the plugin data dir
8
- // and reused on every subsequent start.
5
+ // Registration happens in the web app: the user redeems an invite there, and the
6
+ // signup screen shows the boat's ingest credentials once (see the app's
7
+ // public/ui/login-overlay.js). They paste the write token into this plugin, and that
8
+ // is the whole handshake. The plugin deliberately does NOT sign up on its own
9
+ // one registration path means no half-created accounts and no spent-invite dead ends.
9
10
  //
10
- // Cache-first is NOT an optimisation here, it is correctness: signup consumes a
11
- // single-use invite and creates the account, so calling it twice returns 403
12
- // bad-invite / 409 slug-taken. The cached bundle is the only repeatable path —
13
- // /auth/login issues a session cookie but no token, and the app never persists the
14
- // write token, so there is nothing to re-fetch.
11
+ // Everything else about the bundle is derivable, so only two fields are asked for:
12
+ // bucket = "<slug>_raw" (the app's convention server/auth/registry.js bucketFor)
13
+ // org = "sailkick" (single org, fleet-wide)
15
14
  //
16
- // Dependency-free; never throws.
15
+ // This bundle deliberately carries NO Influx URL. "Which boat am I" and "where is the
16
+ // cloud" are separate questions, and the second one has a single fleet-wide answer that
17
+ // index.js owns. Letting a pasted or cached value set the host is how telemetry ends up
18
+ // spooling to a loopback address forever while the status line looks healthy — the app's
19
+ // signup screen has been handing out its own internal http://localhost:8086.
20
+ //
21
+ // NB the write token cannot be re-fetched: the app shows it once at signup and never
22
+ // stores it (registry.js keeps only passwordHash + influxReadToken). Losing it means
23
+ // minting a new one, so the pasted value is the source of truth here.
17
24
 
18
25
  const fs = require('fs')
19
26
  const path = require('path')
20
27
 
21
- // Terminal = re-running with the same input can only fail the same way; the owner has
22
- // to change something (new invite, different boat name, longer password).
23
- const TERMINAL_CODES = new Set(['bad-invite', 'slug-taken', 'bad-slug', 'weak-password'])
28
+ const DEFAULT_ORG = 'sailkick'
24
29
 
25
30
  function cacheFileFor (app) {
26
31
  const dataDir = (app.getDataDirPath && app.getDataDirPath()) || '.'
27
32
  return path.join(dataDir, 'account.json')
28
33
  }
29
34
 
35
+ // Legacy/manual escape hatch: a hand-written (or pre-0.14.2) account.json still works,
36
+ // so an install that was set up before the token field existed keeps syncing. Any
37
+ // influxUrl in such a file is dropped — those were copied off the signup screen, which
38
+ // was showing the server's own loopback address.
30
39
  function readCache (file) {
31
40
  try {
32
41
  const j = JSON.parse(fs.readFileSync(file, 'utf8'))
33
- return (j && j.writeToken) ? j : null
42
+ if (!j || !j.writeToken) return null
43
+ const { influxUrl, ...rest } = j
44
+ return rest
34
45
  } catch { return null }
35
46
  }
36
47
 
37
- function writeCache (file, bundle) {
38
- try {
39
- fs.mkdirSync(path.dirname(file), { recursive: true })
40
- const tmp = `${file}.tmp-${process.pid}`
41
- fs.writeFileSync(tmp, JSON.stringify(bundle), { mode: 0o600 })
42
- fs.renameSync(tmp, file)
43
- } catch {}
44
- }
45
-
46
- // True when we could pair right now (nothing cached yet, but all three fields present).
47
- function pairable (account) {
48
- return !!(account && account.invite && account.slug && account.password)
49
- }
48
+ const clean = (v) => String(v == null ? '' : v).trim()
50
49
 
51
50
  // Returns one of:
52
- // { bundle, source: 'cache' } already paired no network call was made
53
- // { bundle, source: 'paired' } just provisioned; bundle now cached
54
- // { bundle: null, source: null } not configured (nothing to do)
55
- // { bundle: null, source: null, error, terminal } pairing attempted and failed
51
+ // { bundle, source: 'config' } token pasted into the plugin config
52
+ // { bundle, source: 'cache' } from a hand-written/legacy account.json
53
+ // { bundle: null, source: null, error? }
56
54
  //
57
- // bundle = { slug, influxUrl, org, bucket, writeToken }
58
- async function resolveAccountConfig (app, account, opts = {}) {
59
- const file = cacheFileFor(app)
60
-
61
- const cached = readCache(file)
62
- if (cached) return { bundle: cached, source: 'cache' } // paired already — never call signup again
63
-
64
- if (!pairable(account)) return { bundle: null, source: null }
65
-
66
- const appUrl = String(opts.appUrl || '').replace(/\/+$/, '')
67
- const url = appUrl + '/api/auth/signup'
68
- const slug = String(account.slug).trim().toLowerCase()
69
-
70
- let resp
71
- try {
72
- resp = await fetch(url, {
73
- method: 'POST',
74
- headers: { 'Content-Type': 'application/json' },
75
- body: JSON.stringify({ invite: account.invite, slug, password: account.password, name: slug }),
76
- signal: AbortSignal.timeout(opts.timeoutMs || 20000)
77
- })
78
- } catch (e) {
79
- return { bundle: null, source: null, error: `cannot reach ${appUrl} (${e.message})`, terminal: false }
55
+ // bundle = { slug, org, bucket, writeToken } — no influxUrl by design, see above.
56
+ function resolveAccountConfig (app, account) {
57
+ const acct = account || {}
58
+ const slug = clean(acct.slug).toLowerCase()
59
+ const writeToken = clean(acct.writeToken)
60
+
61
+ if (slug && writeToken) {
62
+ return {
63
+ source: 'config',
64
+ bundle: {
65
+ slug,
66
+ org: clean(acct.org) || DEFAULT_ORG,
67
+ bucket: clean(acct.bucket) || `${slug}_raw`,
68
+ writeToken
69
+ }
70
+ }
80
71
  }
81
72
 
82
- const j = await resp.json().catch(() => null)
83
-
84
- if (!resp.ok || !j || j.ok === false) {
85
- const code = (j && j.code) || `http-${resp.status}`
86
- const message = (j && j.message) || `HTTP ${resp.status}`
87
- return { bundle: null, source: null, error: `${message} (${code})`, terminal: TERMINAL_CODES.has(code) }
88
- }
73
+ const cached = readCache(cacheFileFor(app))
74
+ if (cached) return { bundle: cached, source: 'cache' }
89
75
 
90
- const ingest = j.ingest || {}
91
- if (!ingest.writeToken) {
92
- return { bundle: null, source: null, error: 'signup returned no write token', terminal: false }
93
- }
94
-
95
- // NB: signup returns no readToken (the app keeps it in its own registry), so local
96
- // history falls back to the DB-less ring unless a read token is set by hand.
97
- const bundle = {
98
- slug: (j.boat && j.boat.slug) || slug,
99
- influxUrl: ingest.url,
100
- org: ingest.org,
101
- bucket: ingest.bucket,
102
- writeToken: ingest.writeToken
103
- }
104
- writeCache(file, bundle)
105
- return { bundle, source: 'paired' }
76
+ // Half-filled: say which half, so the status line is actionable.
77
+ if (writeToken) return { bundle: null, source: null, error: 'boat name is missing' }
78
+ if (slug) return { bundle: null, source: null, error: 'write token is missing' }
79
+ return { bundle: null, source: null }
106
80
  }
107
81
 
108
- // True when the plugin either holds a bundle or has enough input to obtain one.
82
+ // True when sync has credentials to work with.
109
83
  function accountConfigured (app, account) {
110
- return !!readCache(cacheFileFor(app)) || pairable(account)
84
+ return !!resolveAccountConfig(app, account).bundle
111
85
  }
112
86
 
113
- module.exports = { resolveAccountConfig, accountConfigured, cacheFileFor }
87
+ module.exports = { resolveAccountConfig, accountConfigured, cacheFileFor, DEFAULT_ORG }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "sailkick-boat",
3
- "version": "0.14.1",
4
- "description": "EARLY ALPHA — cloud telemetry + offline maps for sailkick boats (www.sailkick.io; pairing is invite-only). Gapless boat→cloud telemetry sync to InfluxDB, and a local proxy that keeps the sailkick app and its charts/maps working fully offline on board.",
3
+ "version": "0.14.3",
4
+ "description": "EARLY ALPHA — cloud telemetry + offline maps for sailkick boats (www.sailkick.io; register on the web, paste the write token). Gapless boat→cloud telemetry sync to InfluxDB, and a local proxy that keeps the sailkick app and its charts/maps working fully offline on board.",
5
5
  "main": "index.js",
6
6
  "scripts": {
7
7
  "test": "node --test"