signalk-chiplog 2.15.0 โ†’ 2.16.0

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/CHANGELOG.md CHANGED
@@ -6,6 +6,30 @@ All notable changes to Chiplog are documented here. The format follows
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [2.16.0] - 2026-11-02
10
+
11
+ ### Added
12
+
13
+ - **An API for companion apps**, such as Chiplog Skipper, described in [docs/API.md](docs/API.md):
14
+ - **Versioned**: the public routes are also served under `/plugins/signalk-chiplog/api/v1`, described by an OpenAPI
15
+ document (`GET /api/v1/openapi.json`, also listed by the Signal K server). `GET /state` now says `apiVersions`,
16
+ `pluginVersion`, `features` and the `logbookId`.
17
+ - **Planned passages** (`/plans`): departure, arrival, Signal K route and fallback points, criteria, crew and watches;
18
+ linking a plan to the passage that opens puts the planned crew aboard.
19
+ - **Defects** (`/defects`), with severity, status and links to the passage, plan or journal line they came from.
20
+ - **Tanks at arrival** (`endTanks`): noted as a passage closes, by detection or by the crew, beside those noted at
21
+ departure; both can be set by hand with `PATCH /entries/:id`, and imported with `POST /entries`.
22
+ - **A changes feed** (`GET /changes?since=`), listing the passages, events, crew, plans and defects changed since a
23
+ cursor, deletions included.
24
+ - Every new route is open to read/write users; none needs admin.
25
+ - **Plans and defects in the online backup**, sent whole like the places to a service that keeps them, and brought back
26
+ by a restore.
27
+
28
+ ### Changed
29
+
30
+ - The first backup run after the update sends every passage's content again, without its track: a passage now carries
31
+ its tanks at arrival.
32
+
9
33
  ## [2.15.0] - 2026-10-30
10
34
 
11
35
  ### Added
@@ -535,7 +559,8 @@ First release.
535
559
  - REST API under `/plugins/signalk-chiplog/api`, documented in [docs/API.md](docs/API.md).
536
560
  - Single SQLite database through Node's built-in `node:sqlite`: no native module to build.
537
561
 
538
- [Unreleased]: https://github.com/ricard33/signalk-chiplog/compare/v2.15.0...HEAD
562
+ [Unreleased]: https://github.com/ricard33/signalk-chiplog/compare/v2.16.0...HEAD
563
+ [2.16.0]: https://github.com/ricard33/signalk-chiplog/compare/v2.15.0...v2.16.0
539
564
  [2.15.0]: https://github.com/ricard33/signalk-chiplog/compare/v2.14.0...v2.15.0
540
565
  [2.14.0]: https://github.com/ricard33/signalk-chiplog/compare/v2.13.0...v2.14.0
541
566
  [2.13.0]: https://github.com/ricard33/signalk-chiplog/compare/v2.12.0...v2.13.0
package/README.md CHANGED
@@ -33,6 +33,7 @@ English and French, chosen from the browser's language.
33
33
  - [Passage summary emails](#passage-summary-emails-)
34
34
  - [Retrospective analysis](#retrospective-analysis-)
35
35
  - [Importing from PostgSail](#importing-from-postgsail-)
36
+ - [Companion apps](#companion-apps-)
36
37
  - [Privacy and online services](#privacy-and-online-services-)
37
38
  - [Troubleshooting](#troubleshooting-)
38
39
  - [Limitations](#limitations-)
@@ -682,6 +683,23 @@ node scripts/import-postgsail.js PostgSail_Trip.geojson --url http://boat.local:
682
683
  and landmarks are then looked up in the background, like for any passage. Tide and weather forecasts are not fetched
683
684
  for passages this old.
684
685
 
686
+ ## Companion apps ๐Ÿงญ
687
+
688
+ Chiplog keeps what happened. A companion app, such as Chiplog Skipper on a tablet, can add what was planned and decided
689
+ around it, through the plugin's REST API โ€” nothing to set up in the plugin:
690
+
691
+ - **Planned passages**: departure, arrival, the Signal K route and fallback points, the crew and the watches. Once the
692
+ passage opens, the planned crew is the one recorded aboard.
693
+ - **Defects** noticed under way or after, with their severity and whether they are fixed.
694
+ - **Tanks at arrival**, noted as a passage ends next to those noted at departure, so that what a passage used can be
695
+ worked out. A boat without tank sensors can have them typed in.
696
+ - **Decisions, check-lists, briefings and signatures** appear in the passage's journal as notes.
697
+
698
+ The app connects with a Signal K device access request, like the tablet entry app, and needs read/write access, never
699
+ admin. Plans and defects go to the [online backup](#online-backup-) with the rest of the logbook, when the service keeps
700
+ them. For developers: the API is versioned (`/plugins/signalk-chiplog/api/v1`) and described by an OpenAPI document,
701
+ listed in the Signal K admin under **Documentation**, and in [docs/API.md](docs/API.md).
702
+
685
703
  ## Privacy and online services ๐Ÿ”’
686
704
 
687
705
  - **Place names.** With geocoding on, the position of each departure and arrival that matches no known place is sent to
package/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  const { registerRoutes } = require('./lib/api');
2
+ const { getOpenApi } = require('./lib/openapi');
2
3
  const { openDatabase } = require('./lib/database');
3
4
  const { createPassageDetector, DETECTION_DEFAULTS, TICK_INTERVAL_MS } = require('./lib/detection');
4
5
  const { ApiError } = require('./lib/errors');
@@ -894,6 +895,10 @@ module.exports = function (app) {
894
895
  }
895
896
  };
896
897
 
898
+ // The versioned API's description, which the server lists with its own (Signal K admin,
899
+ // Documentation > OpenAPI).
900
+ plugin.getOpenApi = () => getOpenApi();
901
+
897
902
  // The server registers routes once, before start() and even while the plugin
898
903
  // is disabled, and never removes them: every request must check the database.
899
904
  plugin.registerWithRouter = function (router) {
@@ -913,6 +918,7 @@ module.exports = function (app) {
913
918
  vesselPosition: () => readVesselPosition(app),
914
919
  observeEvent: (entryId, time) => detector.observeEvent(entryId, time),
915
920
  noteDeparture: (entryId) => detector.noteDeparture(entryId),
921
+ noteArrival: (entryId) => detector.noteArrival(entryId),
916
922
  usbExport,
917
923
  summaryMailer,
918
924
  cloudSync,
package/lib/api.js CHANGED
@@ -1,5 +1,9 @@
1
+ const { version: pluginVersion } = require('../package.json');
2
+ const changes = require('./changes');
3
+ const { getLogbookId } = require('./cloud-sync');
1
4
  const { getSchemaVersion } = require('./database');
2
5
  const crew = require('./crew');
6
+ const defects = require('./defects');
3
7
  const { ApiError, badRequest, conflict, notFound } = require('./errors');
4
8
  const entries = require('./entries');
5
9
  const events = require('./events');
@@ -9,7 +13,9 @@ const { isTimeZone, PDF_LANGUAGES } = require('./logbook-pdf');
9
13
  const { toGeoJson, toGpx } = require('./formats');
10
14
  const manoeuvreTypes = require('./manoeuvre-types');
11
15
  const { importPassage, TRACK_READINGS, OBSERVATION_READINGS } = require('./passage-import');
16
+ const { getOpenApi } = require('./openapi');
12
17
  const places = require('./places');
18
+ const plans = require('./plans');
13
19
  const propulsion = require('./propulsion');
14
20
  const { getTideForecast } = require('./tide-forecaster');
15
21
  const { getWeatherForecast } = require('./weather-forecaster');
@@ -19,6 +25,14 @@ const v = require('./validation');
19
25
 
20
26
  const SQLITE_CONSTRAINT = 19;
21
27
 
28
+ // The versioned contract (docs/API.md, Versioning). A field or route added stays in v1; a
29
+ // breaking change is v2, with v1 kept alongside for a while.
30
+ const V1_PREFIX = '/api/v1';
31
+ const API_VERSIONS = [1];
32
+ // What a client may rely on beyond what v1 first described: it sends a field or calls a route
33
+ // that belongs to a feature only once this list names it, since unknown fields are refused.
34
+ const FEATURES = ['plans', 'defects', 'changes', 'endTanks'];
35
+
22
36
  // Servers predating router.access() only support admin-only plugin routes.
23
37
  // Falling back keeps the plugin usable there, at the cost of an admin login.
24
38
  function scoped(router, level) {
@@ -151,6 +165,17 @@ function parseTank(item, name) {
151
165
  };
152
166
  }
153
167
 
168
+ // A list of tanks, or null to clear them.
169
+ function parseTanks(value, name) {
170
+ if (value === null) {
171
+ return null;
172
+ }
173
+ if (!Array.isArray(value)) {
174
+ throw badRequest(`${name} must be an array or null`);
175
+ }
176
+ return value.map((item, index) => parseTank(item, `${name}[${index}]`));
177
+ }
178
+
154
179
  // A battery as noted at departure: volts, amps (negative discharging), state of
155
180
  // charge as a ratio, kelvin.
156
181
  function parseBattery(item, name) {
@@ -188,10 +213,187 @@ function parsePropulsionPeriod(item, name) {
188
213
  };
189
214
  }
190
215
 
216
+ const nullableTimestamp = (value, name) => (value === null ? null : v.parseTimestamp(value, name));
217
+ const nullableId = (value, name) => (value === null ? null : v.parseId(value, name));
218
+
219
+ // The changes feed's cursor: the `cursor` of the previous page, 0 for everything.
220
+ function parseCursor(value) {
221
+ if (value === undefined) {
222
+ return 0;
223
+ }
224
+ if (!/^(0|[1-9]\d*)$/.test(String(value))) {
225
+ throw badRequest('since must be a non-negative integer');
226
+ }
227
+ return Number(value);
228
+ }
229
+
230
+ function parseChangesLimit(value) {
231
+ const limit = value === undefined ? changes.DEFAULT_CHANGES_LIMIT : Number(value);
232
+ if (!Number.isInteger(limit) || limit < 1 || limit > changes.MAX_CHANGES_LIMIT) {
233
+ throw badRequest(`limit must be an integer between 1 and ${changes.MAX_CHANGES_LIMIT}`);
234
+ }
235
+ return limit;
236
+ }
237
+
238
+ // A Signal K resource, by the href its API gives it: /resources/<type>/<id>.
239
+ const resourceRef = (type) => (value, name) => {
240
+ const pattern = new RegExp(`^/resources/${type}/[^/\\s]{1,200}$`);
241
+ if (typeof value !== 'string' || !pattern.test(value)) {
242
+ throw badRequest(`${name} must be a Signal K resource reference: /resources/${type}/<id>`);
243
+ }
244
+ return value;
245
+ };
246
+
247
+ const MAX_PLAN_LIST = 100;
248
+
249
+ function parseArray(value, name, parseItem) {
250
+ if (!Array.isArray(value) || value.length > MAX_PLAN_LIST) {
251
+ throw badRequest(`${name} must be an array of at most ${MAX_PLAN_LIST} items`);
252
+ }
253
+ return value.map((item, index) => parseItem(item, `${name}[${index}]`));
254
+ }
255
+
256
+ // A crew member a plan names: a roster member by id, or a name for this plan alone.
257
+ function parseMember(item, name, extraFields = []) {
258
+ if (!v.isPlainObject(item)) {
259
+ throw badRequest(`${name} must be an object`);
260
+ }
261
+ if ('crewMemberId' in item) {
262
+ const member = v.requireBody(item, ['crewMemberId', 'role', ...extraFields]);
263
+ return {
264
+ crewMemberId: v.parseId(member.crewMemberId, `${name}.crewMemberId`),
265
+ role: optional(member, 'role', nullableString(100))
266
+ };
267
+ }
268
+ const member = v.requireBody(item, ['name', 'role', ...extraFields]);
269
+ return {
270
+ name: v.parseString(member.name, `${name}.name`, { maxLength: 100 }),
271
+ role: optional(member, 'role', nullableString(100))
272
+ };
273
+ }
274
+
275
+ function parseWatch(item, name) {
276
+ const member = parseMember(item, name, ['start', 'end']);
277
+ const start = v.parseTimestamp(item.start, `${name}.start`);
278
+ const end = v.parseTimestamp(item.end, `${name}.end`);
279
+ if (end <= start) {
280
+ throw badRequest(`${name}.end must be after its start`);
281
+ }
282
+ return { start, end, ...member };
283
+ }
284
+
285
+ const PLAN_FIELDS = [
286
+ 'clientRef',
287
+ 'title',
288
+ 'status',
289
+ 'departureTime',
290
+ 'departurePlaceName',
291
+ 'departurePosition',
292
+ 'arrivalTime',
293
+ 'arrivalPlaceName',
294
+ 'arrivalPosition',
295
+ 'routeRef',
296
+ 'fallbackRefs',
297
+ 'criteria',
298
+ 'crew',
299
+ 'watches',
300
+ 'entryId'
301
+ ];
302
+
303
+ function parsePlanInput(body, { creating }) {
304
+ return withoutUndefined({
305
+ clientRef: creating ? optional(body, 'clientRef', requiredString(100)) : undefined,
306
+ title: creating
307
+ ? v.parseString(body.title, 'title', { maxLength: 200 })
308
+ : optional(body, 'title', requiredString(200)),
309
+ status: optional(body, 'status', (value, name) =>
310
+ v.parseEnum(value, name, plans.PLAN_STATUSES)
311
+ ),
312
+ departureTime: optional(body, 'departureTime', nullableTimestamp),
313
+ departurePlaceName: optional(body, 'departurePlaceName', nullableString(200)),
314
+ departurePosition: optional(body, 'departurePosition', v.parsePosition),
315
+ arrivalTime: optional(body, 'arrivalTime', nullableTimestamp),
316
+ arrivalPlaceName: optional(body, 'arrivalPlaceName', nullableString(200)),
317
+ arrivalPosition: optional(body, 'arrivalPosition', v.parsePosition),
318
+ routeRef: optional(body, 'routeRef', (value, name) =>
319
+ value === null ? null : resourceRef('routes')(value, name)
320
+ ),
321
+ fallbackRefs: optional(body, 'fallbackRefs', (value, name) =>
322
+ parseArray(value, name, resourceRef('waypoints'))
323
+ ),
324
+ criteria: optional(body, 'criteria', parsePayload),
325
+ crew: optional(body, 'crew', (value, name) => parseArray(value, name, parseMember)),
326
+ watches: optional(body, 'watches', (value, name) => parseArray(value, name, parseWatch)),
327
+ entryId: optional(body, 'entryId', nullableId)
328
+ });
329
+ }
330
+
331
+ const DEFECT_FIELDS = [
332
+ 'clientRef',
333
+ 'title',
334
+ 'description',
335
+ 'severity',
336
+ 'status',
337
+ 'time',
338
+ 'fixedAt',
339
+ 'entryId',
340
+ 'planId',
341
+ 'eventId'
342
+ ];
343
+
344
+ function parseDefectInput(body, { creating }) {
345
+ return withoutUndefined({
346
+ clientRef: creating ? optional(body, 'clientRef', requiredString(100)) : undefined,
347
+ title: creating
348
+ ? v.parseString(body.title, 'title', { maxLength: 200 })
349
+ : optional(body, 'title', requiredString(200)),
350
+ description: optional(body, 'description', nullableString(10000)),
351
+ severity: optional(body, 'severity', (value, name) =>
352
+ v.parseEnum(value, name, defects.DEFECT_SEVERITIES)
353
+ ),
354
+ status: optional(body, 'status', (value, name) =>
355
+ v.parseEnum(value, name, defects.DEFECT_STATUSES)
356
+ ),
357
+ time: optional(body, 'time', v.parseTimestamp),
358
+ fixedAt: optional(body, 'fixedAt', nullableTimestamp),
359
+ entryId: optional(body, 'entryId', nullableId),
360
+ planId: optional(body, 'planId', nullableId),
361
+ eventId: optional(body, 'eventId', nullableId)
362
+ });
363
+ }
364
+
365
+ const optionalQuery = (query, field, parse) =>
366
+ query[field] === undefined ? undefined : parse(query[field], field);
367
+
368
+ const ROUTE_METHODS = ['get', 'post', 'put', 'patch', 'delete'];
369
+
370
+ // A public route is mounted twice: under /api, which the webapp uses and which may change
371
+ // freely, and under /api/v1, the versioned contract the OpenAPI document describes (see
372
+ // docs/API.md, Versioning). Each one is recorded, for the OpenAPI document to be checked
373
+ // against.
374
+ function versioned(registrar, level, routes) {
375
+ const twice = {};
376
+ for (const method of ROUTE_METHODS) {
377
+ twice[method] = (routePath, ...handlers) => {
378
+ registrar[method](routePath, ...handlers);
379
+ const v1Path = routePath.replace(/^\/api\//, `${V1_PREFIX}/`);
380
+ registrar[method](v1Path, ...handlers);
381
+ routes.push({ method: method.toUpperCase(), path: v1Path.slice(V1_PREFIX.length), level });
382
+ return twice;
383
+ };
384
+ }
385
+ return twice;
386
+ }
387
+
388
+ // Returns the public routes as { method, path, level }, path relative to /api/v1.
191
389
  function registerRoutes(router, { getContext, logError }) {
192
- const readonly = scoped(router, 'readonly');
193
- const readwrite = scoped(router, 'readwrite');
194
- const admin = router;
390
+ const publicRoutes = [];
391
+ const readonly = versioned(scoped(router, 'readonly'), 'readonly', publicRoutes);
392
+ const readwrite = versioned(scoped(router, 'readwrite'), 'readwrite', publicRoutes);
393
+ const admin = versioned(router, 'admin', publicRoutes);
394
+ // The webapp's own configuration and maintenance screens: never part of the contract.
395
+ const internalReadonly = scoped(router, 'readonly');
396
+ const internalAdmin = router;
195
397
 
196
398
  const handle = (fn) => async (req, res) => {
197
399
  try {
@@ -221,11 +423,31 @@ function registerRoutes(router, { getContext, logError }) {
221
423
  motion,
222
424
  propulsion: under,
223
425
  stateIssue,
224
- schemaVersion: getSchemaVersion(db)
426
+ schemaVersion: getSchemaVersion(db),
427
+ logbookId: getLogbookId(db),
428
+ apiVersions: API_VERSIONS,
429
+ pluginVersion,
430
+ features: FEATURES
225
431
  };
226
432
  })
227
433
  );
228
434
 
435
+ readonly.get(
436
+ '/api/openapi.json',
437
+ handle(() => getOpenApi())
438
+ );
439
+
440
+ // Changes feed
441
+ readonly.get(
442
+ '/api/changes',
443
+ handle(({ db }, req) =>
444
+ changes.listChanges(db, {
445
+ since: parseCursor(req.query.since),
446
+ limit: parseChangesLimit(req.query.limit)
447
+ })
448
+ )
449
+ );
450
+
229
451
  // Entries
230
452
 
231
453
  readonly.get(
@@ -264,6 +486,7 @@ function registerRoutes(router, { getContext, logError }) {
264
486
  'distance',
265
487
  'startTanks',
266
488
  'startBatteries',
489
+ 'endTanks',
267
490
  'trackPoints',
268
491
  'observations',
269
492
  'propulsion'
@@ -280,6 +503,7 @@ function registerRoutes(router, { getContext, logError }) {
280
503
  distance: optional(body, 'distance', v.parseNonNegativeNumber),
281
504
  startTanks: parseList(body, 'startTanks', parseTank),
282
505
  startBatteries: parseList(body, 'startBatteries', parseBattery),
506
+ endTanks: parseList(body, 'endTanks', parseTank),
283
507
  trackPoints: parseList(body, 'trackPoints', parseTrackPoint),
284
508
  observations: parseList(body, 'observations', parseObservation),
285
509
  propulsion: parseList(body, 'propulsion', parsePropulsionPeriod)
@@ -302,7 +526,9 @@ function registerRoutes(router, { getContext, logError }) {
302
526
  'endPosition',
303
527
  'startPlaceName',
304
528
  'endPlaceName',
305
- 'distance'
529
+ 'distance',
530
+ 'startTanks',
531
+ 'endTanks'
306
532
  ]);
307
533
  const patch = {
308
534
  startTime: optional(body, 'startTime', v.parseTimestamp),
@@ -311,7 +537,9 @@ function registerRoutes(router, { getContext, logError }) {
311
537
  endPosition: optional(body, 'endPosition', v.parsePosition),
312
538
  startPlaceName: optional(body, 'startPlaceName', nullableString(200)),
313
539
  endPlaceName: optional(body, 'endPlaceName', nullableString(200)),
314
- distance: optional(body, 'distance', v.parseNonNegativeNumber)
540
+ distance: optional(body, 'distance', v.parseNonNegativeNumber),
541
+ startTanks: optional(body, 'startTanks', parseTanks),
542
+ endTanks: optional(body, 'endTanks', parseTanks)
315
543
  };
316
544
  return entries.updateEntry(db, id, patch, {
317
545
  placeMatchRadius: config.placeMatchRadius,
@@ -322,13 +550,16 @@ function registerRoutes(router, { getContext, logError }) {
322
550
 
323
551
  readwrite.post(
324
552
  '/api/entries/:id/close',
325
- handle(({ db, config, now, vesselPosition }, req) =>
326
- entries.closeEntry(db, entryId(req), {
553
+ handle(({ db, config, now, vesselPosition, noteArrival }, req) => {
554
+ const id = entryId(req);
555
+ entries.closeEntry(db, id, {
327
556
  now: now(),
328
557
  position: vesselPosition(),
329
558
  placeMatchRadius: config.placeMatchRadius
330
- })
331
- )
559
+ });
560
+ noteArrival(id);
561
+ return entries.getEntry(db, id);
562
+ })
332
563
  );
333
564
 
334
565
  readwrite.post(
@@ -674,6 +905,98 @@ function registerRoutes(router, { getContext, logError }) {
674
905
  })
675
906
  );
676
907
 
908
+ // Plans
909
+
910
+ readonly.get(
911
+ '/api/plans',
912
+ handle(({ db }, req) =>
913
+ plans.listPlans(db, {
914
+ status: optionalQuery(req.query, 'status', (value, name) =>
915
+ v.parseEnum(value, name, plans.PLAN_STATUSES)
916
+ ),
917
+ entryId: optionalQuery(req.query, 'entryId', v.parseId),
918
+ ...v.parsePagination(req.query)
919
+ })
920
+ )
921
+ );
922
+
923
+ readonly.get(
924
+ '/api/plans/:id',
925
+ handle(({ db }, req) => plans.getPlan(db, v.parseId(req.params.id)))
926
+ );
927
+
928
+ readwrite.post(
929
+ '/api/plans',
930
+ handle(({ db, now }, req, res) => {
931
+ const input = parsePlanInput(v.requireBody(req.body, PLAN_FIELDS), { creating: true });
932
+ const { plan, created } = plans.createPlan(db, input, now());
933
+ res.status(created ? 201 : 200);
934
+ return plan;
935
+ })
936
+ );
937
+
938
+ readwrite.patch(
939
+ '/api/plans/:id',
940
+ handle(({ db, now }, req) => {
941
+ const id = v.parseId(req.params.id);
942
+ const body = v.requireBody(
943
+ req.body,
944
+ PLAN_FIELDS.filter((field) => field !== 'clientRef')
945
+ );
946
+ return plans.updatePlan(db, id, parsePlanInput(body, { creating: false }), now());
947
+ })
948
+ );
949
+
950
+ readwrite.delete(
951
+ '/api/plans/:id',
952
+ handle(({ db }, req) => {
953
+ plans.deletePlan(db, v.parseId(req.params.id));
954
+ })
955
+ );
956
+
957
+ // Defects
958
+
959
+ readonly.get(
960
+ '/api/defects',
961
+ handle(({ db }, req) =>
962
+ defects.listDefects(db, {
963
+ status: optionalQuery(req.query, 'status', (value, name) =>
964
+ v.parseEnum(value, name, defects.DEFECT_STATUSES)
965
+ ),
966
+ entryId: optionalQuery(req.query, 'entryId', v.parseId),
967
+ planId: optionalQuery(req.query, 'planId', v.parseId),
968
+ ...v.parsePagination(req.query)
969
+ })
970
+ )
971
+ );
972
+
973
+ readonly.get(
974
+ '/api/defects/:id',
975
+ handle(({ db }, req) => defects.getDefect(db, v.parseId(req.params.id)))
976
+ );
977
+
978
+ readwrite.post(
979
+ '/api/defects',
980
+ handle(({ db, now }, req, res) => {
981
+ const input = parseDefectInput(v.requireBody(req.body, DEFECT_FIELDS), { creating: true });
982
+ const { defect, created } = defects.createDefect(db, input, now());
983
+ res.status(created ? 201 : 200);
984
+ return defect;
985
+ })
986
+ );
987
+
988
+ readwrite.patch(
989
+ '/api/defects/:id',
990
+ handle(({ db, now }, req) => {
991
+ const id = v.parseId(req.params.id);
992
+ const body = v.requireBody(
993
+ req.body,
994
+ DEFECT_FIELDS.filter((field) => field !== 'clientRef')
995
+ );
996
+ return defects.updateDefect(db, id, parseDefectInput(body, { creating: false }), now());
997
+ })
998
+ );
999
+
677
1000
  // Export
678
1001
 
679
1002
  readonly.get(
@@ -707,13 +1030,13 @@ function registerRoutes(router, { getContext, logError }) {
707
1030
  })
708
1031
  );
709
1032
 
710
- readonly.get(
1033
+ internalReadonly.get(
711
1034
  '/api/export/usb',
712
1035
  handle(({ config, usbExport }) => ({ directory: config.usbExportPath, ...usbExport.status() }))
713
1036
  );
714
1037
 
715
1038
  // Goes through the scheduler, so it never overlaps an automatic copy.
716
- admin.post(
1039
+ internalAdmin.post(
717
1040
  '/api/export/usb',
718
1041
  handle(({ config, usbExport }) => {
719
1042
  if (!config.usbExportPath) {
@@ -726,14 +1049,14 @@ function registerRoutes(router, { getContext, logError }) {
726
1049
  })
727
1050
  );
728
1051
 
729
- readonly.get(
1052
+ internalReadonly.get(
730
1053
  '/api/summary-mail',
731
1054
  handle(({ summaryMailer }) => summaryMailer.status())
732
1055
  );
733
1056
 
734
1057
  // Sends one passage's summary now, whether or not it already went out: how
735
1058
  // the SMTP settings are tried without waiting for the next arrival.
736
- admin.post(
1059
+ internalAdmin.post(
737
1060
  '/api/entries/:id/summary-mail',
738
1061
  handle(async ({ db, summaryMailer }, req) => {
739
1062
  const id = entryId(req);
@@ -746,7 +1069,7 @@ function registerRoutes(router, { getContext, logError }) {
746
1069
  })
747
1070
  );
748
1071
 
749
- readonly.get(
1072
+ internalReadonly.get(
750
1073
  '/api/cloud-sync',
751
1074
  handle(({ cloudSync, cloudPairing, cloudRestore }) => ({
752
1075
  ...cloudSync.status(),
@@ -756,7 +1079,7 @@ function registerRoutes(router, { getContext, logError }) {
756
1079
  );
757
1080
 
758
1081
  // The earlier logbooks of this boat the service can give back, asked of the service now.
759
- readonly.get(
1082
+ internalReadonly.get(
760
1083
  '/api/cloud-sync/restore',
761
1084
  handle(async ({ cloudRestore }) => {
762
1085
  try {
@@ -769,7 +1092,7 @@ function registerRoutes(router, { getContext, logError }) {
769
1092
 
770
1093
  // Starts bringing one of them back into this logbook, which must be empty; the restore
771
1094
  // goes on in the background and GET /cloud-sync follows it.
772
- admin.post(
1095
+ internalAdmin.post(
773
1096
  '/api/cloud-sync/restore',
774
1097
  handle(({ cloudRestore }, req) => {
775
1098
  const logbookId = req.body?.logbookId;
@@ -787,7 +1110,7 @@ function registerRoutes(router, { getContext, logError }) {
787
1110
 
788
1111
  // Forgets the pairing: the device token is removed from the plugin configuration and the
789
1112
  // backup turns off. The service keeps what it holds, and the device until revoked there.
790
- admin.delete(
1113
+ internalAdmin.delete(
791
1114
  '/api/cloud-sync/token',
792
1115
  handle(async ({ cloudSync, cloudPairing, cloudRestore, removePairing }) => {
793
1116
  try {
@@ -806,7 +1129,7 @@ function registerRoutes(router, { getContext, logError }) {
806
1129
  // Asks the service for a pairing code, then waits in the background for someone to claim
807
1130
  // it; GET /cloud-sync follows the pairing. The service is the configured one unless `url`
808
1131
  // names another.
809
- admin.post(
1132
+ internalAdmin.post(
810
1133
  '/api/cloud-sync/pairing',
811
1134
  handle(async ({ cloudPairing }, req) => {
812
1135
  const url = req.body?.url;
@@ -824,7 +1147,7 @@ function registerRoutes(router, { getContext, logError }) {
824
1147
  })
825
1148
  );
826
1149
 
827
- admin.delete(
1150
+ internalAdmin.delete(
828
1151
  '/api/cloud-sync/pairing',
829
1152
  handle(({ cloudPairing }) => {
830
1153
  cloudPairing.cancel();
@@ -834,7 +1157,7 @@ function registerRoutes(router, { getContext, logError }) {
834
1157
 
835
1158
  // Starts a backup run now rather than at the next interval; the run itself
836
1159
  // goes on in the background, through its schedule, never alongside another.
837
- admin.post(
1160
+ internalAdmin.post(
838
1161
  '/api/cloud-sync',
839
1162
  handle(({ cloudSync, nudgeCloudSync }) => {
840
1163
  const status = cloudSync.status();
@@ -852,7 +1175,7 @@ function registerRoutes(router, { getContext, logError }) {
852
1175
  );
853
1176
 
854
1177
  // Declares the link costly, or cheap again (SPEC ยง4.17).
855
- admin.post(
1178
+ internalAdmin.post(
856
1179
  '/api/cloud-sync/costly-link',
857
1180
  handle(async ({ cloudSync, saveCostlyLink }, req) => {
858
1181
  if (typeof req.body?.costly !== 'boolean') {
@@ -867,12 +1190,12 @@ function registerRoutes(router, { getContext, logError }) {
867
1190
  })
868
1191
  );
869
1192
 
870
- readonly.get(
1193
+ internalReadonly.get(
871
1194
  '/api/replay',
872
1195
  handle(({ replayJob }) => replayJob.status())
873
1196
  );
874
1197
 
875
- admin.post(
1198
+ internalAdmin.post(
876
1199
  '/api/replay',
877
1200
  handle(({ replayJob }, req) => {
878
1201
  const from = v.parseTimestamp(req.body.from, 'from');
@@ -884,7 +1207,7 @@ function registerRoutes(router, { getContext, logError }) {
884
1207
  })
885
1208
  );
886
1209
 
887
- admin.post(
1210
+ internalAdmin.post(
888
1211
  '/api/replay/cancel',
889
1212
  handle(({ replayJob }) => {
890
1213
  if (!replayJob.cancel()) {
@@ -892,6 +1215,8 @@ function registerRoutes(router, { getContext, logError }) {
892
1215
  }
893
1216
  })
894
1217
  );
1218
+
1219
+ return publicRoutes;
895
1220
  }
896
1221
 
897
- module.exports = { registerRoutes };
1222
+ module.exports = { registerRoutes, API_VERSIONS, FEATURES, V1_PREFIX };