@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/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 storage.putToken({ ...minted, lastUsedAt: new Date(now).toISOString() });
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
- return json({ ok: true }, created ? 201 : 200);
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 = (await request.json());
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
- return json({ ok: true, released: matches.length });
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 = (await request.json());
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
- if ((await storage.getTokenByHash(tokenHash)) === undefined) {
670
- return error('Unknown token', 404);
671
- }
672
- await storage.deleteToken(tokenHash);
673
- return json({ ok: true });
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(new Uint8Array(await request.arrayBuffer()))
1082
- : (await request.json());
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
- const offer = await selectOffer(updated);
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 no update,
1232
- // and `parseOffer` reads a body with no offer fields as "no update".
1233
- if (offer === undefined)
1234
- return respond(respondName === undefined ? null : { name: respondName });
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
- ...(respondName === undefined ? {} : { name: respondName }),
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