@mikrojs/registry 0.18.0 → 0.18.1
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 +19 -16
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/memoryStorage.d.ts.map +1 -1
- package/dist/memoryStorage.js +8 -1
- package/dist/memoryStorage.js.map +1 -1
- package/dist/node.d.ts.map +1 -1
- package/dist/node.js +10 -1
- package/dist/node.js.map +1 -1
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +490 -19
- package/dist/registry.js.map +1 -1
- package/dist/types.d.ts +35 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/util.d.ts +2 -0
- package/dist/util.d.ts.map +1 -1
- package/dist/util.js +4 -0
- package/dist/util.js.map +1 -1
- package/package.json +3 -2
package/dist/registry.js
CHANGED
|
@@ -1,7 +1,26 @@
|
|
|
1
|
+
import { applyDefaults } from '@mikrojs/native/runtime/schema/core';
|
|
2
|
+
import { deriveOverlay, diffConfigSchemas, parseConfigSchema, parseEffective, structuralEquals, } from '@mikrojs/native/runtime/schema/shared';
|
|
1
3
|
import semver from 'semver';
|
|
2
4
|
import { decodeCbor } from './cbor.js';
|
|
3
5
|
import { bearerToken, cbor, CLIENT_IP_HEADER, DEFAULT_CHANNEL, error, escapeHtml, firmwareRange, html, isSameOriginPost, json, normalizeUserCode, randomSecret, randomUserCode, readCappedBody, REGISTRY_NAME, secretsMatch, sha256Hex, } from './util.js';
|
|
4
6
|
const CHECKSUM_RE = /^[0-9a-f]{64}$/;
|
|
7
|
+
/** Both config caps are byte caps — the device parses the overlay out of a
|
|
8
|
+
* fixed response buffer — so they are measured on encoded UTF-8, not on
|
|
9
|
+
* string length, which undercounts non-Latin text by up to 3x. */
|
|
10
|
+
const UTF8 = new TextEncoder();
|
|
11
|
+
/** Publish cap on the serialized config schema (spec, "The config schema"). */
|
|
12
|
+
const MAX_CONFIG_SCHEMA_BYTES = 16 * 1024;
|
|
13
|
+
/** Cap on the encoded EFFECTIVE config document: the device materializes it
|
|
14
|
+
* by spreading the served overlay over its manifest defaults, and it is what
|
|
15
|
+
* its JS heap then holds (spec, "Serving safely"). Enforced where the
|
|
16
|
+
* effective document is computed: at PUT, and again per serve, since the
|
|
17
|
+
* same stored overlay serves every release the device might run. The served
|
|
18
|
+
* overlay needs no cap of its own: it is a subset of the effective
|
|
19
|
+
* document's top-level entries, so this bound covers it. */
|
|
20
|
+
const MAX_CONFIG_DOC_BYTES = 4 * 1024;
|
|
21
|
+
const MAX_CONFIG_REV_LENGTH = 64;
|
|
22
|
+
const MAX_CONFIG_MESSAGE_LENGTH = 256;
|
|
23
|
+
const MAX_CONFIG_PATH_LENGTH = 64;
|
|
5
24
|
const LOGIN_SESSION_TTL_MS = 10 * 60 * 1000;
|
|
6
25
|
/** Live browser logins held at once. Creating one is unauthenticated, so
|
|
7
26
|
* without a ceiling anyone can grow the map indefinitely. Far above what real
|
|
@@ -34,6 +53,13 @@ const MAX_APPROVE_BUCKETS = 10_000;
|
|
|
34
53
|
* before anything can validate it. Publish is exempt: it carries a build, and
|
|
35
54
|
* the host adapter caps that one. Generous next to a session or approve form. */
|
|
36
55
|
const MAX_LOGIN_BODY_BYTES = 64 * 1024;
|
|
56
|
+
/** Ceiling for the authenticated JSON/CBOR routes (enroll, release, the config
|
|
57
|
+
* PUT, and check-in). They buffer a body before anything can validate it, and
|
|
58
|
+
* only the `serve` adapter caps that: a bare `{fetch}` export deployed to
|
|
59
|
+
* another host has no ceiling of its own. Generous next to what any of them
|
|
60
|
+
* legitimately carries: a check-in report is a few hundred bytes, and config
|
|
61
|
+
* values are bounded by the 4 KiB effective-document cap plus its envelope. */
|
|
62
|
+
const MAX_BODY_BYTES = 16 * 1024;
|
|
37
63
|
/** Caps on device-reported strings; a device must not be able to grow its own
|
|
38
64
|
* record without bound. Generous next to real values. */
|
|
39
65
|
const MAX_DEVICE_ID_LENGTH = 128;
|
|
@@ -106,6 +132,27 @@ export function createRegistry(options) {
|
|
|
106
132
|
}
|
|
107
133
|
}
|
|
108
134
|
}
|
|
135
|
+
/** Token writes run one at a time, so a revocation cannot be undone by a
|
|
136
|
+
* `lastUsedAt` refresh that read the record before the delete landed and
|
|
137
|
+
* writes it back afterwards, expiry and all. Its own queue, not the device
|
|
138
|
+
* one: the device handlers call grantFor from inside that queue, so sharing
|
|
139
|
+
* a queue would deadlock. Single-process only, like the login flow. */
|
|
140
|
+
let tokenWriteQueue = Promise.resolve();
|
|
141
|
+
function serializeTokenWrite(run) {
|
|
142
|
+
const next = tokenWriteQueue.then(run, run);
|
|
143
|
+
tokenWriteQueue = next.catch(() => undefined);
|
|
144
|
+
return next;
|
|
145
|
+
}
|
|
146
|
+
/** Refresh a token's `lastUsedAt`, unless it was revoked in the meantime:
|
|
147
|
+
* re-read inside the queue and write only what is still there. */
|
|
148
|
+
function touchToken(tokenHash, now) {
|
|
149
|
+
return serializeTokenWrite(async () => {
|
|
150
|
+
const current = await storage.getTokenByHash(tokenHash);
|
|
151
|
+
if (current === undefined)
|
|
152
|
+
return;
|
|
153
|
+
await storage.putToken({ ...current, lastUsedAt: new Date(now).toISOString() });
|
|
154
|
+
});
|
|
155
|
+
}
|
|
109
156
|
async function grantFor(request) {
|
|
110
157
|
if (options.verifyAdmin) {
|
|
111
158
|
return (await options.verifyAdmin(request)) ? { admin: true } : undefined;
|
|
@@ -128,7 +175,7 @@ export function createRegistry(options) {
|
|
|
128
175
|
return undefined;
|
|
129
176
|
const lastUsedAt = Date.parse(minted.lastUsedAt ?? '');
|
|
130
177
|
if (!Number.isFinite(lastUsedAt) || now - lastUsedAt >= TOKEN_USE_WRITE_INTERVAL_MS) {
|
|
131
|
-
await
|
|
178
|
+
await touchToken(minted.tokenHash, now);
|
|
132
179
|
}
|
|
133
180
|
return minted.app === undefined ? { admin: false } : { app: minted.app, admin: false };
|
|
134
181
|
}
|
|
@@ -326,6 +373,42 @@ export function createRegistry(options) {
|
|
|
326
373
|
return error('Invalid bytecodeVersion', 400);
|
|
327
374
|
if (!(file instanceof Blob))
|
|
328
375
|
return error('Missing build file', 400);
|
|
376
|
+
// The app's config schema, when it declares one. Rejected here, at
|
|
377
|
+
// publish, rather than stored and failed at serve time: this is where the
|
|
378
|
+
// author can still rename a field or add a default (spec, "The config
|
|
379
|
+
// schema").
|
|
380
|
+
const configSchemaText = text('configSchema');
|
|
381
|
+
let configSchema;
|
|
382
|
+
if (configSchemaText !== undefined) {
|
|
383
|
+
if (UTF8.encode(configSchemaText).byteLength > MAX_CONFIG_SCHEMA_BYTES) {
|
|
384
|
+
return error(`configSchema must be at most ${MAX_CONFIG_SCHEMA_BYTES} bytes`, 400);
|
|
385
|
+
}
|
|
386
|
+
let parsed;
|
|
387
|
+
try {
|
|
388
|
+
parsed = JSON.parse(configSchemaText);
|
|
389
|
+
}
|
|
390
|
+
catch {
|
|
391
|
+
return error('configSchema must be JSON', 400);
|
|
392
|
+
}
|
|
393
|
+
const checked = parseConfigSchema(parsed);
|
|
394
|
+
if (!checked.ok) {
|
|
395
|
+
const where = checked.error.path === '' ? '' : ` at ${checked.error.path}`;
|
|
396
|
+
return error(`Invalid configSchema${where}: ${checked.error.message}`, 400);
|
|
397
|
+
}
|
|
398
|
+
configSchema = checked.value;
|
|
399
|
+
// Defaults alone over the served-document cap means nothing could ever
|
|
400
|
+
// be served for this release: every authored config would be rejected
|
|
401
|
+
// and rule 5 would pause every rollout, discovered one device at a
|
|
402
|
+
// time. Reject at publish, where the schema author can trim.
|
|
403
|
+
const defaults = parseEffective(configSchema, undefined);
|
|
404
|
+
if (defaults.ok) {
|
|
405
|
+
const bytes = UTF8.encode(JSON.stringify(defaults.value)).byteLength;
|
|
406
|
+
if (bytes > MAX_CONFIG_DOC_BYTES) {
|
|
407
|
+
return error(`configSchema defaults alone encode to ${bytes} bytes, over the ` +
|
|
408
|
+
`${MAX_CONFIG_DOC_BYTES}-byte served-document cap`, 400);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
329
412
|
// Release immutability: same (app, version, firmwareRange) with the
|
|
330
413
|
// same checksum is an idempotent success (CI retry-safe); a different
|
|
331
414
|
// checksum is a conflict, so bump the version instead. The range, not the
|
|
@@ -342,6 +425,16 @@ export function createRegistry(options) {
|
|
|
342
425
|
if (owned !== undefined) {
|
|
343
426
|
return error(`This build is already published under ${owned.app}`, 409);
|
|
344
427
|
}
|
|
428
|
+
// The schema belongs to the release, not the build: every variant of
|
|
429
|
+
// (app, version) is packed from the same source, so a differing schema is
|
|
430
|
+
// a conflict exactly like a differing checksum. An absent field on a later
|
|
431
|
+
// variant (an older CLI) asserts nothing and keeps the stored one.
|
|
432
|
+
const storedSchema = await storage.getConfigSchema(app, version);
|
|
433
|
+
if (configSchema !== undefined &&
|
|
434
|
+
storedSchema !== undefined &&
|
|
435
|
+
!structuralEquals(storedSchema.schema, configSchema)) {
|
|
436
|
+
return error(`Release ${app}@${version} already exists with a different config schema`, 409);
|
|
437
|
+
}
|
|
345
438
|
// Store the build without serving it. A build is only served once a channel
|
|
346
439
|
// points at it (a `channel` field here, or a later `release`), so publishing
|
|
347
440
|
// and releasing are separate: a bare `push` uploads and returns.
|
|
@@ -398,11 +491,24 @@ export function createRegistry(options) {
|
|
|
398
491
|
await storage.putBuild(record);
|
|
399
492
|
created = true;
|
|
400
493
|
}
|
|
494
|
+
// Advisory (spec, "Schema changes between releases"): diff a newly stored
|
|
495
|
+
// schema against the app's latest prior release and tell the publisher in
|
|
496
|
+
// the response, while a rename or a new default is still cheap.
|
|
497
|
+
const warnings = [];
|
|
498
|
+
if (configSchema !== undefined && storedSchema === undefined) {
|
|
499
|
+
const previous = await latestConfigSchema(app, version);
|
|
500
|
+
if (previous !== undefined)
|
|
501
|
+
warnings.push(...diffConfigSchemas(previous, configSchema));
|
|
502
|
+
// Written on the first publish of the release and never rewritten.
|
|
503
|
+
await storage.putConfigSchema({ app, version, schema: configSchema });
|
|
504
|
+
}
|
|
401
505
|
// Serve it only when a channel is named. main promotes the build record; a
|
|
402
506
|
// named channel gets a pointer.
|
|
403
|
-
if (channel !== undefined)
|
|
507
|
+
if (channel !== undefined) {
|
|
404
508
|
await pointChannel(record, channel);
|
|
405
|
-
|
|
509
|
+
warnings.push(...(await configCompatWarnings(app, channel, version)));
|
|
510
|
+
}
|
|
511
|
+
return json({ ok: true, ...(warnings.length > 0 ? { warnings } : {}) }, created ? 201 : 200);
|
|
406
512
|
}
|
|
407
513
|
// ── Release ──────────────────────────────────────────────────────
|
|
408
514
|
/** Point a channel at a build already in the registry: `mikro ota release
|
|
@@ -412,9 +518,12 @@ export function createRegistry(options) {
|
|
|
412
518
|
const grant = await grantFor(request);
|
|
413
519
|
if (grant === undefined)
|
|
414
520
|
return error('Unauthorized', 401);
|
|
521
|
+
const raw = await readCappedBody(request, MAX_BODY_BYTES);
|
|
522
|
+
if (raw === undefined)
|
|
523
|
+
return error('Request body too large', 413);
|
|
415
524
|
let body;
|
|
416
525
|
try {
|
|
417
|
-
body = (
|
|
526
|
+
body = JSON.parse(new TextDecoder().decode(raw));
|
|
418
527
|
}
|
|
419
528
|
catch {
|
|
420
529
|
return error('Expected a JSON body', 400);
|
|
@@ -440,7 +549,12 @@ export function createRegistry(options) {
|
|
|
440
549
|
}
|
|
441
550
|
for (const build of matches)
|
|
442
551
|
await pointChannel(build, channel);
|
|
443
|
-
|
|
552
|
+
const warnings = await configCompatWarnings(app, channel, version);
|
|
553
|
+
return json({
|
|
554
|
+
ok: true,
|
|
555
|
+
released: matches.length,
|
|
556
|
+
...(warnings.length > 0 ? { warnings } : {}),
|
|
557
|
+
});
|
|
444
558
|
}
|
|
445
559
|
// ── Download ─────────────────────────────────────────────────────
|
|
446
560
|
/**
|
|
@@ -556,9 +670,12 @@ export function createRegistry(options) {
|
|
|
556
670
|
const grant = await grantFor(request);
|
|
557
671
|
if (grant === undefined)
|
|
558
672
|
return error('Unauthorized', 401);
|
|
673
|
+
const raw = await readCappedBody(request, MAX_BODY_BYTES);
|
|
674
|
+
if (raw === undefined)
|
|
675
|
+
return error('Request body too large', 413);
|
|
559
676
|
let body;
|
|
560
677
|
try {
|
|
561
|
-
body = (
|
|
678
|
+
body = JSON.parse(new TextDecoder().decode(raw));
|
|
562
679
|
}
|
|
563
680
|
catch {
|
|
564
681
|
return error('Expected a JSON body', 400);
|
|
@@ -630,6 +747,83 @@ export function createRegistry(options) {
|
|
|
630
747
|
await storage.putDevice({ ...device, updateKeyHash: await sha256Hex(updateKey) });
|
|
631
748
|
return json({ credential: updateKey });
|
|
632
749
|
}
|
|
750
|
+
/**
|
|
751
|
+
* Set (or clear) a device's config overlay. Reference-registry authoring:
|
|
752
|
+
* how operators author config is a registry's own business (spec, "Out of
|
|
753
|
+
* scope"), and this is the minimal version of it. The body's `values` are
|
|
754
|
+
* derived to an overlay against one release's schema — unknown keys
|
|
755
|
+
* dropped, defaults stripped, empty containers pruned — so what is stored
|
|
756
|
+
* is only deviations, and clearing is deriving nothing. Saving values that
|
|
757
|
+
* do not produce a valid effective config is a 400, message and path
|
|
758
|
+
* included, rather than something stored and withheld later.
|
|
759
|
+
*/
|
|
760
|
+
async function handleSetConfig(request, deviceId) {
|
|
761
|
+
const grant = await grantFor(request);
|
|
762
|
+
if (grant === undefined)
|
|
763
|
+
return error('Unauthorized', 401);
|
|
764
|
+
const device = await deviceForGrant(grant, deviceId);
|
|
765
|
+
if (device === undefined)
|
|
766
|
+
return error('Unknown device', 404);
|
|
767
|
+
if (device.app === undefined) {
|
|
768
|
+
return error('Device has no app binding; re-enroll it with one', 400);
|
|
769
|
+
}
|
|
770
|
+
const raw = await readCappedBody(request, MAX_BODY_BYTES);
|
|
771
|
+
if (raw === undefined)
|
|
772
|
+
return error('Request body too large', 413);
|
|
773
|
+
let body;
|
|
774
|
+
try {
|
|
775
|
+
body = JSON.parse(new TextDecoder().decode(raw));
|
|
776
|
+
}
|
|
777
|
+
catch {
|
|
778
|
+
return error('Expected a JSON body', 400);
|
|
779
|
+
}
|
|
780
|
+
if (body.version !== undefined && typeof body.version !== 'string') {
|
|
781
|
+
return error('Invalid version', 400);
|
|
782
|
+
}
|
|
783
|
+
// Author against the named release, or the one the device is running: the
|
|
784
|
+
// schema the values will actually be validated against at serve time.
|
|
785
|
+
const version = body.version ?? device.runningVersion;
|
|
786
|
+
if (version === undefined) {
|
|
787
|
+
return error('Pass a version: the device has not reported one yet', 400);
|
|
788
|
+
}
|
|
789
|
+
const record = await storage.getConfigSchema(device.app, version);
|
|
790
|
+
if (record === undefined) {
|
|
791
|
+
return error(`No config schema for ${device.app}@${version}`, 404);
|
|
792
|
+
}
|
|
793
|
+
const schema = record.schema;
|
|
794
|
+
const overrides = deriveOverlay(schema, body.values);
|
|
795
|
+
const effective = parseEffective(schema, overrides);
|
|
796
|
+
if (!effective.ok) {
|
|
797
|
+
const where = effective.error.path === '' ? '' : ` at ${effective.error.path}`;
|
|
798
|
+
return error(`Invalid config${where}: ${effective.error.message}`, 400);
|
|
799
|
+
}
|
|
800
|
+
// The document cap, enforced at authoring and measured on the EFFECTIVE
|
|
801
|
+
// document (defaults folded in), which is what the device's JS heap ends
|
|
802
|
+
// up holding once it spreads the served overlay over its defaults. An
|
|
803
|
+
// oversized save accepted here would be withheld at every check-in with
|
|
804
|
+
// only a server log line, and rule 5 would silently pause this device's
|
|
805
|
+
// rollout with it.
|
|
806
|
+
if (overrides !== undefined) {
|
|
807
|
+
const bytes = UTF8.encode(JSON.stringify(effective.value)).byteLength;
|
|
808
|
+
if (bytes > MAX_CONFIG_DOC_BYTES) {
|
|
809
|
+
return error(`The effective config encodes to ${bytes} bytes, over the ${MAX_CONFIG_DOC_BYTES}-byte cap`, 400);
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
const updated = { ...device, configAuthoredFor: version };
|
|
813
|
+
if (overrides === undefined) {
|
|
814
|
+
delete updated.configOverrides;
|
|
815
|
+
delete updated.configAuthoredFor;
|
|
816
|
+
}
|
|
817
|
+
else {
|
|
818
|
+
updated.configOverrides = overrides;
|
|
819
|
+
}
|
|
820
|
+
await storage.putDevice(updated);
|
|
821
|
+
return json({
|
|
822
|
+
ok: true,
|
|
823
|
+
version,
|
|
824
|
+
...(overrides === undefined ? {} : { overrides }),
|
|
825
|
+
});
|
|
826
|
+
}
|
|
633
827
|
/** Clear a device's failure list, so a build that failed for a reason since
|
|
634
828
|
* fixed (a full filesystem, a bad network) can be offered again. */
|
|
635
829
|
async function handleClearFailures(request, deviceId) {
|
|
@@ -666,11 +860,16 @@ export function createRegistry(options) {
|
|
|
666
860
|
return error('Unauthorized', 401);
|
|
667
861
|
if (!grant.admin)
|
|
668
862
|
return error('Only the registry secret may manage tokens', 403);
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
863
|
+
// Queued with the `lastUsedAt` refreshes, so the delete is the last write:
|
|
864
|
+
// a refresh already holding the record either lands before it or, finding
|
|
865
|
+
// the record gone on its re-read, writes nothing.
|
|
866
|
+
return serializeTokenWrite(async () => {
|
|
867
|
+
if ((await storage.getTokenByHash(tokenHash)) === undefined) {
|
|
868
|
+
return error('Unknown token', 404);
|
|
869
|
+
}
|
|
870
|
+
await storage.deleteToken(tokenHash);
|
|
871
|
+
return json({ ok: true });
|
|
872
|
+
});
|
|
674
873
|
}
|
|
675
874
|
// ── Browser login (spec §5) ──────────────────────────────────────
|
|
676
875
|
async function handleCreateLoginSession(request) {
|
|
@@ -996,10 +1195,107 @@ mints a token with exactly that access and hands it to the waiting CLI.</p>
|
|
|
996
1195
|
return undefined;
|
|
997
1196
|
return { reason, detail };
|
|
998
1197
|
}
|
|
1198
|
+
/** `{checksum, reason, detail?}` per the spec's Declined offers section.
|
|
1199
|
+
* Carries its own checksum, so unlike lastInstall it needs no attribution
|
|
1200
|
+
* against whatever was offered last. */
|
|
1201
|
+
function parseLastDecline(value) {
|
|
1202
|
+
if (typeof value !== 'object' || value === null)
|
|
1203
|
+
return undefined;
|
|
1204
|
+
const { checksum, reason, detail } = value;
|
|
1205
|
+
if (typeof checksum !== 'string' || !/^[0-9a-f]{64}$/.test(checksum))
|
|
1206
|
+
return undefined;
|
|
1207
|
+
if (typeof reason !== 'string' || reason === '' || reason.length > MAX_REASON_LENGTH) {
|
|
1208
|
+
return undefined;
|
|
1209
|
+
}
|
|
1210
|
+
if (detail === undefined)
|
|
1211
|
+
return { checksum, reason };
|
|
1212
|
+
if (typeof detail !== 'string' || detail.length > MAX_DETAIL_LENGTH)
|
|
1213
|
+
return undefined;
|
|
1214
|
+
return { checksum, reason, detail };
|
|
1215
|
+
}
|
|
999
1216
|
function namePair(record) {
|
|
1000
1217
|
const rev = record.nameRev ?? 0;
|
|
1001
1218
|
return record.name === undefined ? [rev] : [rev, record.name];
|
|
1002
1219
|
}
|
|
1220
|
+
/** `{rev, message, path?}` per the spec's Config sync section. Rejected
|
|
1221
|
+
* rather than trimmed, like lastInstall: device-supplied, stored, shown. */
|
|
1222
|
+
function parseConfigError(value) {
|
|
1223
|
+
if (typeof value !== 'object' || value === null)
|
|
1224
|
+
return undefined;
|
|
1225
|
+
const { rev, message, path } = value;
|
|
1226
|
+
if (typeof rev !== 'string' || rev === '' || rev.length > MAX_CONFIG_REV_LENGTH) {
|
|
1227
|
+
return undefined;
|
|
1228
|
+
}
|
|
1229
|
+
if (typeof message !== 'string' ||
|
|
1230
|
+
message === '' ||
|
|
1231
|
+
message.length > MAX_CONFIG_MESSAGE_LENGTH) {
|
|
1232
|
+
return undefined;
|
|
1233
|
+
}
|
|
1234
|
+
if (path === undefined)
|
|
1235
|
+
return { rev, message };
|
|
1236
|
+
if (typeof path !== 'string' || path.length > MAX_CONFIG_PATH_LENGTH)
|
|
1237
|
+
return undefined;
|
|
1238
|
+
return { rev, message, path };
|
|
1239
|
+
}
|
|
1240
|
+
/**
|
|
1241
|
+
* The config to serve a device for one target release, or undefined when the
|
|
1242
|
+
* release has no schema. Per-serve adaptation of the stored overlay (filter,
|
|
1243
|
+
* strip, prune via deriveOverlay), then merge-validate: validation happens
|
|
1244
|
+
* here or nowhere, since the device does none. An overlay that does not
|
|
1245
|
+
* produce a valid effective config is withheld, never sent (spec, "Serving
|
|
1246
|
+
* safely"), which is also what offer rule 5 gates on.
|
|
1247
|
+
*
|
|
1248
|
+
* What is SERVED is the deviation overlay against the target release's
|
|
1249
|
+
* defaults, which the device resolves with one top-level spread. `rev`
|
|
1250
|
+
* stays the identity of the EFFECTIVE document, so two overlays that mean
|
|
1251
|
+
* the same config share a token and a value moved back onto its default is
|
|
1252
|
+
* not re-served. Nothing deviating serves nothing at all: the defaults
|
|
1253
|
+
* baked into the build's own manifest already cover that device.
|
|
1254
|
+
*/
|
|
1255
|
+
async function configForRelease(device, version) {
|
|
1256
|
+
if (device.app === undefined)
|
|
1257
|
+
return undefined;
|
|
1258
|
+
const record = await storage.getConfigSchema(device.app, version);
|
|
1259
|
+
if (record === undefined)
|
|
1260
|
+
return undefined;
|
|
1261
|
+
const schema = record.schema;
|
|
1262
|
+
const overrides = deriveOverlay(schema, device.configOverrides);
|
|
1263
|
+
const effective = parseEffective(schema, overrides);
|
|
1264
|
+
if (!effective.ok) {
|
|
1265
|
+
const reason = `${effective.error.message} at ${effective.error.path || '(root)'}`;
|
|
1266
|
+
// eslint-disable-next-line no-console
|
|
1267
|
+
console.warn(`checkin: withholding config for ${device.deviceId} (${device.app}@${version}): ${reason}`);
|
|
1268
|
+
return { valid: false, reason };
|
|
1269
|
+
}
|
|
1270
|
+
if (overrides === undefined)
|
|
1271
|
+
return { valid: true };
|
|
1272
|
+
const doc = effective.value;
|
|
1273
|
+
const bytes = UTF8.encode(JSON.stringify(doc)).byteLength;
|
|
1274
|
+
if (bytes > MAX_CONFIG_DOC_BYTES) {
|
|
1275
|
+
const reason = `effective config is ${bytes} bytes, over the ${MAX_CONFIG_DOC_BYTES}-byte cap`;
|
|
1276
|
+
// eslint-disable-next-line no-console
|
|
1277
|
+
console.warn(`checkin: withholding config for ${device.deviceId} (${device.app}@${version}): ${reason}`);
|
|
1278
|
+
return { valid: false, reason };
|
|
1279
|
+
}
|
|
1280
|
+
// The token identifies the EFFECTIVE document for the version it was
|
|
1281
|
+
// validated against, not the overlay that expresses it: a save that moves
|
|
1282
|
+
// a value back onto its default changes the overlay and must not read as
|
|
1283
|
+
// a new config. The device never computes one, it echoes this. Hashed
|
|
1284
|
+
// over a key-sorted encoding so a storage backend that normalizes key
|
|
1285
|
+
// order (jsonb, CBOR maps) does not churn revs for identical documents.
|
|
1286
|
+
// Truncated to 64 bits: the token is an identity compared against one
|
|
1287
|
+
// device's echo, never a proof, and the full digest costs 48 extra bytes
|
|
1288
|
+
// in every check-in and in each device NVS slot that stores it.
|
|
1289
|
+
const rev = (await sha256Hex(`${version}\u0000${stableStringify(doc)}`)).slice(0, 16);
|
|
1290
|
+
// What travels is only what deviates from this release's defaults; the
|
|
1291
|
+
// device resolves it with one top-level spread over its manifest copy.
|
|
1292
|
+
const overlay = deviationOverlay(applyDefaults(schema, undefined), doc);
|
|
1293
|
+
// Nothing deviates: the device should hold no document at all, the same
|
|
1294
|
+
// state as holding no overrides above.
|
|
1295
|
+
if (Object.keys(overlay).length === 0)
|
|
1296
|
+
return { valid: true };
|
|
1297
|
+
return { valid: true, rev, doc: overlay };
|
|
1298
|
+
}
|
|
1003
1299
|
/** The build a channel currently serves for `(app, firmware range)`, or
|
|
1004
1300
|
* undefined. `main` is the highest-promotedAt build, the pre-channels
|
|
1005
1301
|
* default, so existing builds keep serving with no migration; a build with
|
|
@@ -1062,6 +1358,47 @@ mints a token with exactly that access and hands it to the waiting CLI.</p>
|
|
|
1062
1358
|
return undefined;
|
|
1063
1359
|
return current;
|
|
1064
1360
|
}
|
|
1361
|
+
/** The most recent prior release of `app` that stored a config schema,
|
|
1362
|
+
* for the publish-time diff. Recency by build creation, newest first. */
|
|
1363
|
+
async function latestConfigSchema(app, excludeVersion) {
|
|
1364
|
+
const builds = (await storage.listBuilds())
|
|
1365
|
+
.filter((b) => b.app === app && b.version !== excludeVersion)
|
|
1366
|
+
.sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1));
|
|
1367
|
+
const seen = new Set();
|
|
1368
|
+
for (const build of builds) {
|
|
1369
|
+
if (seen.has(build.version))
|
|
1370
|
+
continue;
|
|
1371
|
+
seen.add(build.version);
|
|
1372
|
+
const record = await storage.getConfigSchema(app, build.version);
|
|
1373
|
+
if (record !== undefined)
|
|
1374
|
+
return record.schema;
|
|
1375
|
+
}
|
|
1376
|
+
return undefined;
|
|
1377
|
+
}
|
|
1378
|
+
/**
|
|
1379
|
+
* The release-time compat report (spec, "Schema changes between releases"):
|
|
1380
|
+
* which devices on this channel hold config that will not validate under
|
|
1381
|
+
* `version`, so rule 5 will withhold the release from them. Advisory — the
|
|
1382
|
+
* serve pipeline stays safe without it — but it is what explains a paused
|
|
1383
|
+
* rollout before anyone asks.
|
|
1384
|
+
*/
|
|
1385
|
+
async function configCompatWarnings(app, channel, version) {
|
|
1386
|
+
if ((await storage.getConfigSchema(app, version)) === undefined)
|
|
1387
|
+
return [];
|
|
1388
|
+
const warnings = [];
|
|
1389
|
+
for (const device of await storage.listDevices()) {
|
|
1390
|
+
if (device.app !== app)
|
|
1391
|
+
continue;
|
|
1392
|
+
if ((device.channel ?? DEFAULT_CHANNEL) !== channel)
|
|
1393
|
+
continue;
|
|
1394
|
+
const state = await configForRelease(device, version);
|
|
1395
|
+
if (state !== undefined && !state.valid) {
|
|
1396
|
+
warnings.push(`device ${device.deviceId}: ${state.reason ?? 'config does not validate'}; ` +
|
|
1397
|
+
`not offered ${version} until fixed`);
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
return warnings;
|
|
1401
|
+
}
|
|
1065
1402
|
async function handleCheckin(request) {
|
|
1066
1403
|
const deviceId = await deviceIdFor(request);
|
|
1067
1404
|
if (deviceId === undefined)
|
|
@@ -1075,11 +1412,14 @@ mints a token with exactly that access and hands it to the waiting CLI.</p>
|
|
|
1075
1412
|
// devices act on the status alone and never parse them.
|
|
1076
1413
|
const isCbor = (request.headers.get('content-type') ?? '').includes('application/cbor');
|
|
1077
1414
|
const respond = isCbor ? cbor : json;
|
|
1415
|
+
const raw = await readCappedBody(request, MAX_BODY_BYTES);
|
|
1416
|
+
if (raw === undefined)
|
|
1417
|
+
return error('Request body too large', 413);
|
|
1078
1418
|
let body;
|
|
1079
1419
|
try {
|
|
1080
1420
|
body = isCbor
|
|
1081
|
-
? decodeCbor(
|
|
1082
|
-
: (
|
|
1421
|
+
? decodeCbor(raw)
|
|
1422
|
+
: JSON.parse(new TextDecoder().decode(raw));
|
|
1083
1423
|
}
|
|
1084
1424
|
catch {
|
|
1085
1425
|
return error(isCbor ? 'Expected a CBOR body' : 'Expected a JSON body', 400);
|
|
@@ -1148,6 +1488,44 @@ mints a token with exactly that access and hands it to the waiting CLI.</p>
|
|
|
1148
1488
|
}
|
|
1149
1489
|
updated.lastFree = body.free;
|
|
1150
1490
|
}
|
|
1491
|
+
// Config sync intake mirrors the wire exactly: an echoed token stands for
|
|
1492
|
+
// "this is what I hold", absence for "I hold nothing" (or firmware without
|
|
1493
|
+
// config sync), and a configError is present exactly while the device
|
|
1494
|
+
// holds a document its app rejects.
|
|
1495
|
+
if (body.configRev !== undefined) {
|
|
1496
|
+
if (typeof body.configRev !== 'string' || body.configRev.length > MAX_CONFIG_REV_LENGTH) {
|
|
1497
|
+
return bad('configRev');
|
|
1498
|
+
}
|
|
1499
|
+
updated.lastConfigRev = body.configRev;
|
|
1500
|
+
}
|
|
1501
|
+
else {
|
|
1502
|
+
delete updated.lastConfigRev;
|
|
1503
|
+
}
|
|
1504
|
+
if (body.configError !== undefined) {
|
|
1505
|
+
const configError = parseConfigError(body.configError);
|
|
1506
|
+
if (configError === undefined)
|
|
1507
|
+
return bad('configError');
|
|
1508
|
+
updated.configError = configError;
|
|
1509
|
+
}
|
|
1510
|
+
else {
|
|
1511
|
+
delete updated.configError;
|
|
1512
|
+
}
|
|
1513
|
+
if (body.lastDecline !== undefined && body.lastDecline !== null) {
|
|
1514
|
+
const lastDecline = parseLastDecline(body.lastDecline);
|
|
1515
|
+
if (lastDecline === undefined)
|
|
1516
|
+
return bad('lastDecline');
|
|
1517
|
+
updated.lastDecline = lastDecline;
|
|
1518
|
+
// Only `abandoned` is evidence about the bytes. A device on poor wifi
|
|
1519
|
+
// produces `exhausted` and `download-failed` for a build that is fine
|
|
1520
|
+
// everywhere else, and withholding it from the whole fleet on that basis
|
|
1521
|
+
// would turn one flaky device into a stalled rollout.
|
|
1522
|
+
if (lastDecline.reason === 'abandoned') {
|
|
1523
|
+
updated.failedChecksums = [
|
|
1524
|
+
...(updated.failedChecksums ?? []).filter((checksum) => checksum !== lastDecline.checksum),
|
|
1525
|
+
lastDecline.checksum,
|
|
1526
|
+
].slice(-MAX_FAILED_CHECKSUMS);
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1151
1529
|
if (body.lastInstall !== undefined && body.lastInstall !== null) {
|
|
1152
1530
|
const lastInstall = parseLastInstall(body.lastInstall);
|
|
1153
1531
|
if (lastInstall === undefined)
|
|
@@ -1224,14 +1602,56 @@ mints a token with exactly that access and hands it to the waiting CLI.</p>
|
|
|
1224
1602
|
delete updated.discardedName;
|
|
1225
1603
|
}
|
|
1226
1604
|
}
|
|
1227
|
-
|
|
1605
|
+
let offer = await selectOffer(updated);
|
|
1606
|
+
// Offer rule 5: the offer and its config are a pair, so a device is never
|
|
1607
|
+
// booted into a release whose config cannot exist. A schema that adds a
|
|
1608
|
+
// required field pauses this device's rollout, visibly, until an operator
|
|
1609
|
+
// supplies the value.
|
|
1610
|
+
let offerConfig;
|
|
1611
|
+
if (offer !== undefined) {
|
|
1612
|
+
offerConfig = await configForRelease(updated, offer.version);
|
|
1613
|
+
if (offerConfig !== undefined && !offerConfig.valid)
|
|
1614
|
+
offer = undefined;
|
|
1615
|
+
}
|
|
1228
1616
|
if (offer !== undefined)
|
|
1229
1617
|
updated.lastOfferedChecksum = offer.checksum;
|
|
1618
|
+
// The config to send, per the spec's pairing rule: for the offered release
|
|
1619
|
+
// when there is an offer, for the running release otherwise. Sent only
|
|
1620
|
+
// when the device's echoed token differs from what it should hold; a
|
|
1621
|
+
// registry-side clear is `config` with no `doc` key.
|
|
1622
|
+
let config;
|
|
1623
|
+
const target = offer !== undefined
|
|
1624
|
+
? { version: offer.version, state: offerConfig }
|
|
1625
|
+
: updated.runningVersion !== undefined
|
|
1626
|
+
? {
|
|
1627
|
+
version: updated.runningVersion,
|
|
1628
|
+
state: await configForRelease(updated, updated.runningVersion),
|
|
1629
|
+
}
|
|
1630
|
+
: undefined;
|
|
1631
|
+
if (target?.state !== undefined && target.state.valid) {
|
|
1632
|
+
const { rev, doc } = target.state;
|
|
1633
|
+
if (rev === undefined) {
|
|
1634
|
+
// Nothing deviates from the defaults: the device should hold no
|
|
1635
|
+
// document at all (its manifest defaults stand in). Clear one it
|
|
1636
|
+
// still echoes.
|
|
1637
|
+
if (updated.lastConfigRev !== undefined)
|
|
1638
|
+
config = { version: target.version };
|
|
1639
|
+
}
|
|
1640
|
+
else if (updated.lastConfigRev !== rev) {
|
|
1641
|
+
config = { rev, version: target.version, doc };
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1230
1644
|
await storage.putDevice(updated);
|
|
1231
|
-
// A name to hand back still has to reach the device when there's
|
|
1232
|
-
// and `parseOffer` reads a body with no offer fields as "no
|
|
1233
|
-
|
|
1234
|
-
|
|
1645
|
+
// A name or config to hand back still has to reach the device when there's
|
|
1646
|
+
// no update, and `parseOffer` reads a body with no offer fields as "no
|
|
1647
|
+
// update"; devices ignore fields they do not know.
|
|
1648
|
+
const extras = {
|
|
1649
|
+
...(respondName === undefined ? {} : { name: respondName }),
|
|
1650
|
+
...(config === undefined ? {} : { config }),
|
|
1651
|
+
};
|
|
1652
|
+
if (offer === undefined) {
|
|
1653
|
+
return respond(Object.keys(extras).length === 0 ? null : extras);
|
|
1654
|
+
}
|
|
1235
1655
|
const origin = options.baseUrl ?? new URL(request.url).origin;
|
|
1236
1656
|
return respond({
|
|
1237
1657
|
// What to fetch and how to verify it, and nothing the device would have to
|
|
@@ -1242,7 +1662,7 @@ mints a token with exactly that access and hands it to the waiting CLI.</p>
|
|
|
1242
1662
|
checksum: offer.checksum,
|
|
1243
1663
|
size: offer.size,
|
|
1244
1664
|
version: offer.version,
|
|
1245
|
-
...
|
|
1665
|
+
...extras,
|
|
1246
1666
|
});
|
|
1247
1667
|
}
|
|
1248
1668
|
// ── Router ───────────────────────────────────────────────────────
|
|
@@ -1294,6 +1714,13 @@ mints a token with exactly that access and hands it to the waiting CLI.</p>
|
|
|
1294
1714
|
? error('Unknown device', 404)
|
|
1295
1715
|
: serializeDeviceWrite(() => handleClearFailures(request, id));
|
|
1296
1716
|
}
|
|
1717
|
+
const deviceConfig = /^\/api\/v1\/devices\/([^/]+)\/config$/.exec(path);
|
|
1718
|
+
if (deviceConfig && request.method === 'PUT') {
|
|
1719
|
+
const id = deviceIdFromPath(deviceConfig[1]);
|
|
1720
|
+
return id === undefined
|
|
1721
|
+
? error('Unknown device', 404)
|
|
1722
|
+
: serializeDeviceWrite(() => handleSetConfig(request, id));
|
|
1723
|
+
}
|
|
1297
1724
|
if (path === '/api/v1/checkin' && request.method === 'POST') {
|
|
1298
1725
|
return serializeDeviceWrite(() => handleCheckin(request));
|
|
1299
1726
|
}
|
|
@@ -1318,4 +1745,48 @@ mints a token with exactly that access and hands it to the waiting CLI.</p>
|
|
|
1318
1745
|
}
|
|
1319
1746
|
return { fetch: handle };
|
|
1320
1747
|
}
|
|
1748
|
+
// ── Config helpers ─────────────────────────────────────────────────
|
|
1749
|
+
function isPlainObject(value) {
|
|
1750
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
1751
|
+
}
|
|
1752
|
+
/**
|
|
1753
|
+
* The overlay to serve: the top-level entries of the effective document that
|
|
1754
|
+
* deviate from the release's defaults, each carried WHOLE. The device merges
|
|
1755
|
+
* with one top-level spread and holds no schema, so granularity is top-level
|
|
1756
|
+
* throughout: a deviating leaf inside a nested plain object ships its whole
|
|
1757
|
+
* top-level value, and a wholesale unit (taggedUnion, array, tuple) is either
|
|
1758
|
+
* equal to its default as a unit and omitted, or present in full.
|
|
1759
|
+
*
|
|
1760
|
+
* Nothing is ever pruned inside a value. With a default `{mode: {kind: 'a',
|
|
1761
|
+
* x: 1}}` and an effective `{mode: {kind: 'b', x: 1}}`, dropping the
|
|
1762
|
+
* equal-looking `x` would leave the device spreading branch a's `x` into
|
|
1763
|
+
* branch b: they are different schema nodes, so "equal to default" is not
|
|
1764
|
+
* defined across them.
|
|
1765
|
+
*/
|
|
1766
|
+
function deviationOverlay(defaults, doc) {
|
|
1767
|
+
// Both come from applyDefaults over a schema whose root is an object(), and
|
|
1768
|
+
// `doc` has already validated against it.
|
|
1769
|
+
if (!isPlainObject(doc))
|
|
1770
|
+
return {};
|
|
1771
|
+
const base = isPlainObject(defaults) ? defaults : {};
|
|
1772
|
+
const out = {};
|
|
1773
|
+
for (const key of Object.keys(doc)) {
|
|
1774
|
+
if (Object.hasOwn(base, key) && structuralEquals(doc[key], base[key]))
|
|
1775
|
+
continue;
|
|
1776
|
+
out[key] = doc[key];
|
|
1777
|
+
}
|
|
1778
|
+
return out;
|
|
1779
|
+
}
|
|
1780
|
+
/** JSON with object keys sorted, for hashing only — never for the wire. */
|
|
1781
|
+
function stableStringify(value) {
|
|
1782
|
+
if (Array.isArray(value))
|
|
1783
|
+
return `[${value.map(stableStringify).join(',')}]`;
|
|
1784
|
+
if (isPlainObject(value)) {
|
|
1785
|
+
const parts = Object.keys(value)
|
|
1786
|
+
.sort()
|
|
1787
|
+
.map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`);
|
|
1788
|
+
return `{${parts.join(',')}}`;
|
|
1789
|
+
}
|
|
1790
|
+
return JSON.stringify(value);
|
|
1791
|
+
}
|
|
1321
1792
|
//# sourceMappingURL=registry.js.map
|