@volter/world-runtime 3.0.156 → 3.0.157
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/src/changeset.js +5 -5
- package/dist/src/served-world.d.ts +5 -0
- package/dist/src/served-world.js +36 -3
- package/package.json +3 -3
- package/src/changeset.ts +5 -5
- package/src/served-world.ts +33 -3
package/dist/src/changeset.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { errorMessage } from "./error-message.js";
|
|
2
2
|
import { worldChangesetRules } from "./configs.js";
|
|
3
|
-
import { blobDigest, captureHistory, captureParentHistory, getActiveBlobStore, getActiveWorldStore, withHistoryLocks, worldPaths } from '@volter/world-core/runtime';
|
|
3
|
+
import { blobDigest, captureHistory, captureParentHistory, getActiveBlobStore, getActiveWorldStore, resourceBlobFile, resourceBlobKey, withHistoryLocks, worldPaths } from '@volter/world-core/runtime';
|
|
4
4
|
// `volter-world mark` / `diff` / `changeset` — the WORLD-side verbs over the changeset
|
|
5
5
|
// primitive (docs/concepts/the-model.md v0). Thin, exactly like `covers` and `tail` are thin: all
|
|
6
6
|
// the state logic (markers, deltas, content hashing, replay) lives in @volter/world-core's
|
|
@@ -24,7 +24,7 @@ import { blobDigest, captureHistory, captureParentHistory, getActiveBlobStore, g
|
|
|
24
24
|
// Read-only over the twins' data at rest, like `tail` — except `replay`, which is the one verb
|
|
25
25
|
// here that WRITES, and it writes only through the control plane's kernel write path.
|
|
26
26
|
import { tmpdir } from 'node:os';
|
|
27
|
-
import { join,
|
|
27
|
+
import { join, resolve } from 'node:path';
|
|
28
28
|
import { approveChangeset, rebaseChangeset, buildChangeset, captureMarker, changesetHashMatches, changesetReadiness, diffLedgers, normalizeChangeset, replayChangeset, runChangesetVerifiers, stateDirName, withApplication, withVerification, worldBootMarker, assertSafeChangesetName, assertMarkerBelongsTo, CHANGESET_KIND, MARKER_KIND, WORLD_BOOT_MARKER_ID, } from '@volter/world-core/runtime';
|
|
29
29
|
import { instanceDir, isWorldOrigin, listWorlds, statusWorld } from "./runtime.js";
|
|
30
30
|
import { fetchFromOrigin } from "./origin.js";
|
|
@@ -436,7 +436,7 @@ export async function pushWorldChangeset(name, options) {
|
|
|
436
436
|
const twins = [...new Map(located.changeset.actions.map((a) => [a.service, a.stateService ?? a.service])).entries()];
|
|
437
437
|
const ownBlobs = async (twin, state) => {
|
|
438
438
|
const dir = worldPaths(state, join(instance.dirs.data, twin)).resources;
|
|
439
|
-
return (await blobs.list(dir)).map((path) => ({ key:
|
|
439
|
+
return (await blobs.list(dir)).map((path) => ({ key: resourceBlobKey(dir, path), path }));
|
|
440
440
|
};
|
|
441
441
|
// content-addressed means a segment of the key IS the digest of the bytes it holds: such bytes never
|
|
442
442
|
// change. A name built on a digest of something else (a record keyed by a hash of an action id, a
|
|
@@ -465,12 +465,12 @@ export async function pushWorldChangeset(name, options) {
|
|
|
465
465
|
for (const { key, path } of await ownBlobs(twin, state)) {
|
|
466
466
|
if (!(await wanted(key, path)))
|
|
467
467
|
continue;
|
|
468
|
-
const held = await blobs.get(
|
|
468
|
+
const held = await blobs.get(resourceBlobFile(there, key));
|
|
469
469
|
if (held !== null && blobDigest(held) === (await digestOf(path)))
|
|
470
470
|
continue;
|
|
471
471
|
const bytes = await blobs.get(path);
|
|
472
472
|
if (bytes)
|
|
473
|
-
await blobs.put(
|
|
473
|
+
await blobs.put(resourceBlobFile(there, key), bytes);
|
|
474
474
|
}
|
|
475
475
|
}
|
|
476
476
|
return;
|
|
@@ -419,6 +419,11 @@ export declare class WorldDoors {
|
|
|
419
419
|
private enforcesRead;
|
|
420
420
|
/** The vendor wire: `/<org>/<world>/<vendor>/<rest>` → the twin's own URL. */
|
|
421
421
|
private wire;
|
|
422
|
+
/** A page's links to sites this World serves (the hostnames its twins claim, a Worker's custom domain among them),
|
|
423
|
+
* rewritten to where the World shows each (siteUrlOf), so a person who follows one stays in the World. The page's own
|
|
424
|
+
* vendor's hosts (`own`) are its own to place, and a site the World does not hold keeps its real address. Null
|
|
425
|
+
* when there is nothing to do: not HTML, compressed, or no site served. */
|
|
426
|
+
private inWorldLinks;
|
|
422
427
|
private linksPath;
|
|
423
428
|
/** The World's links: name → the vendor origin it forwards to. */
|
|
424
429
|
links(): Record<string, {
|
package/dist/src/served-world.js
CHANGED
|
@@ -46,7 +46,7 @@ import { fetchFromOrigin } from "./origin.js";
|
|
|
46
46
|
import { CONSOLE_BASE, consoleRedirect, serveConsoleApart } from "./console-apart.js";
|
|
47
47
|
import { loadWorldConfig, worldChangesetRules, writeWorldConfig } from "./configs.js";
|
|
48
48
|
import { adaptersFor, credentialPath, credentialPayloadFrom, deployTwin, deployWorld, loadWorldChecks, twinSigningSecret, materializeRoots, refreshPosture, refreshTwin, rootForControlRoot, rewrapTwinCredentials, sealTwinCredential, sealedCredentialInfo, bindLegacyCredentials, setTwinRoot, spanMs } from "./root.js";
|
|
49
|
-
import { describeActionFields } from '@volter/world-core/runtime';
|
|
49
|
+
import { describeActionFields, resourceBlobFile } from '@volter/world-core/runtime';
|
|
50
50
|
import { worldLedgers } from "./changeset.js";
|
|
51
51
|
import { clockFile, downWorld, instanceDir, isWorldOrigin, listWorlds, readWorldBootRecord, statusWorld, upWorld } from "./runtime.js";
|
|
52
52
|
import { assertChangesetRules } from "./schema.js";
|
|
@@ -1305,12 +1305,21 @@ export class WorldDoors {
|
|
|
1305
1305
|
}
|
|
1306
1306
|
catch { /* left as answered */ }
|
|
1307
1307
|
}
|
|
1308
|
+
// A page a site answers links another site the World serves by its hostname (www.videogame.ai's link to
|
|
1309
|
+
// cyclotron.videogame.ai): it opens where the World shows that site, as a browser redirect does above.
|
|
1310
|
+
const at = request.method !== 'HEAD' ? this.host.sitesTemplate?.() : undefined;
|
|
1311
|
+
const linked = at ? await this.inWorldLinks(answer, out, url.origin, at, undefined, browser) : null;
|
|
1312
|
+
if (linked)
|
|
1313
|
+
return linked;
|
|
1308
1314
|
return new Response(request.method === 'HEAD' ? null : answer.body, { status: answer.status, statusText: answer.statusText, headers: out });
|
|
1309
1315
|
}
|
|
1310
1316
|
// a person's browser is told in words (often the site is not there yet, or was not in a copy of the World as it was)
|
|
1311
1317
|
if ((request.headers.get('accept') ?? '').includes('text/html')) {
|
|
1312
1318
|
const shown = host.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
|
|
1313
|
-
|
|
1319
|
+
// A link here from a page in the World (a post's URL) names a site the World does not hold: the person may open
|
|
1320
|
+
// the real one, by a click of their own (an answered redirect would make the World's origin an open redirect).
|
|
1321
|
+
const real = `https://${host}${path}${url.search}`.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
|
|
1322
|
+
return new Response(`<!doctype html><meta charset="utf-8"><title>${shown}</title><body style="font:16px system-ui;margin:3em;color:#555"><p>Nothing is served at <b>${shown}</b> in this World.</p><p><a href="${real}" rel="noopener noreferrer">Open ${shown} outside the World</a></p></body>`, { status: 404, headers: { 'content-type': 'text/html; charset=utf-8' } });
|
|
1314
1323
|
}
|
|
1315
1324
|
return Response.json({ error: `no twin of ${this.served} answers ${host}` }, { status: 404 });
|
|
1316
1325
|
}
|
|
@@ -1566,8 +1575,32 @@ export class WorldDoors {
|
|
|
1566
1575
|
if (!/^\s*(?:__Host-)?volter_(?:world|pages|browser)=/i.test(c))
|
|
1567
1576
|
out.append('set-cookie', c);
|
|
1568
1577
|
}
|
|
1578
|
+
// a twin's page links a site this World serves (a post's URL, a Worker's custom domain) by its hostname: the
|
|
1579
|
+
// person who follows it stays in the World
|
|
1580
|
+
if (sitesAt && request.method === 'GET' && answer.status < 300) {
|
|
1581
|
+
const linked = await this.inWorldLinks(answer, out, url.origin, sitesAt, vendor);
|
|
1582
|
+
if (linked)
|
|
1583
|
+
return linked;
|
|
1584
|
+
}
|
|
1569
1585
|
return new Response(answer.body, { status: answer.status, headers: out });
|
|
1570
1586
|
}
|
|
1587
|
+
/** A page's links to sites this World serves (the hostnames its twins claim, a Worker's custom domain among them),
|
|
1588
|
+
* rewritten to where the World shows each (siteUrlOf), so a person who follows one stays in the World. The page's own
|
|
1589
|
+
* vendor's hosts (`own`) are its own to place, and a site the World does not hold keeps its real address. Null
|
|
1590
|
+
* when there is nothing to do: not HTML, compressed, or no site served. */
|
|
1591
|
+
async inWorldLinks(answer, out, origin, template, own, browser) {
|
|
1592
|
+
if (!answer.body || !(out.get('content-type') ?? '').includes('text/html') || out.has('content-encoding'))
|
|
1593
|
+
return null;
|
|
1594
|
+
const served = new Set((await this.claimedHosts(origin)).filter(row => row.vendor !== own).flatMap(row => row.hosts.map(h => h.toLowerCase())));
|
|
1595
|
+
if (served.size === 0)
|
|
1596
|
+
return null;
|
|
1597
|
+
const html = (await answer.text()).replace(/(href|action)="https?:\/\/([A-Za-z0-9.-]+)(?::\d+)?((?:[/?#][^"]*)?)"/g, (match, attr, to, rest) => {
|
|
1598
|
+
const next = served.has(to.toLowerCase()) ? siteUrlOf(template, to.toLowerCase(), browser) : null;
|
|
1599
|
+
return next ? `${attr}="${next}${rest || '/'}"` : match;
|
|
1600
|
+
});
|
|
1601
|
+
out.delete('content-length');
|
|
1602
|
+
return new Response(html, { status: answer.status, statusText: answer.statusText, headers: out });
|
|
1603
|
+
}
|
|
1571
1604
|
linksPath() { return join(this.worldRoot, stateDirName(), 'links.json'); }
|
|
1572
1605
|
/** The World's links: name → the vendor origin it forwards to. */
|
|
1573
1606
|
links() {
|
|
@@ -2266,7 +2299,7 @@ export class WorldDoors {
|
|
|
2266
2299
|
return Response.json({ error: 'a digest in a blob key is lowercase hex' }, { status: 400 });
|
|
2267
2300
|
if (!this.up().services[name])
|
|
2268
2301
|
return Response.json({ error: `no twin "${name}" in ${this.served}` }, { status: 404 });
|
|
2269
|
-
const path =
|
|
2302
|
+
const path = resourceBlobFile(worldPaths(this.stateOf(name), this.controlRoot(name)).resources, key);
|
|
2270
2303
|
const store = getActiveBlobStore();
|
|
2271
2304
|
// HEAD names the blob's own digest, so a push can tell the same key holding other bytes from the same blob
|
|
2272
2305
|
if (request.method === 'HEAD') {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/world-runtime",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.157",
|
|
4
4
|
"description": "World configs for twins: boot named local runtimes, allocate ports, generate world.env/instance.json, and run apps against fake-key twin worlds.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"twin",
|
|
@@ -70,14 +70,14 @@
|
|
|
70
70
|
"node": ">=22.3"
|
|
71
71
|
},
|
|
72
72
|
"peerDependencies": {
|
|
73
|
-
"@volter/world-core": "3.0.
|
|
73
|
+
"@volter/world-core": "3.0.149",
|
|
74
74
|
"@volter/world-console": "3.0.149"
|
|
75
75
|
},
|
|
76
76
|
"dependencies": {
|
|
77
77
|
"@electric-sql/pglite": "0.5.8",
|
|
78
78
|
"@electric-sql/pglite-pgvector": "0.0.9",
|
|
79
79
|
"@volter/world-access": "3.0.1",
|
|
80
|
-
"@volter/world-core": "3.0.
|
|
80
|
+
"@volter/world-core": "3.0.149",
|
|
81
81
|
"mongodb": "~6.20.0",
|
|
82
82
|
"pg-gateway": "0.3.0-beta.4",
|
|
83
83
|
"smol-toml": "^1.8.0",
|
package/src/changeset.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { errorMessage } from './error-message.ts';
|
|
2
2
|
import { worldChangesetRules } from './configs.ts';
|
|
3
|
-
import { blobDigest, captureHistory, captureParentHistory, getActiveBlobStore, getActiveWorldStore, originHead, withHistoryLocks, worldPaths, type HistoryReference } from '@volter/world-core/runtime';
|
|
3
|
+
import { blobDigest, captureHistory, captureParentHistory, getActiveBlobStore, getActiveWorldStore, originHead, resourceBlobFile, resourceBlobKey, withHistoryLocks, worldPaths, type HistoryReference } from '@volter/world-core/runtime';
|
|
4
4
|
// `volter-world mark` / `diff` / `changeset` — the WORLD-side verbs over the changeset
|
|
5
5
|
// primitive (docs/concepts/the-model.md v0). Thin, exactly like `covers` and `tail` are thin: all
|
|
6
6
|
// the state logic (markers, deltas, content hashing, replay) lives in @volter/world-core's
|
|
@@ -24,7 +24,7 @@ import { blobDigest, captureHistory, captureParentHistory, getActiveBlobStore, g
|
|
|
24
24
|
// Read-only over the twins' data at rest, like `tail` — except `replay`, which is the one verb
|
|
25
25
|
// here that WRITES, and it writes only through the control plane's kernel write path.
|
|
26
26
|
import { tmpdir } from 'node:os';
|
|
27
|
-
import { join,
|
|
27
|
+
import { join, resolve } from 'node:path';
|
|
28
28
|
import {
|
|
29
29
|
approveChangeset,
|
|
30
30
|
rebaseChangeset,
|
|
@@ -524,7 +524,7 @@ export async function pushWorldChangeset(name: string, options: PushWorldOptions
|
|
|
524
524
|
const twins = [...new Map(located.changeset.actions.map((a) => [a.service, a.stateService ?? a.service])).entries()];
|
|
525
525
|
const ownBlobs = async (twin: string, state: string): Promise<Array<{ key: string; path: string }>> => {
|
|
526
526
|
const dir = worldPaths(state, join(instance.dirs.data, twin)).resources;
|
|
527
|
-
return (await blobs.list(dir)).map((path) => ({ key:
|
|
527
|
+
return (await blobs.list(dir)).map((path) => ({ key: resourceBlobKey(dir, path), path }));
|
|
528
528
|
};
|
|
529
529
|
// content-addressed means a segment of the key IS the digest of the bytes it holds: such bytes never
|
|
530
530
|
// change. A name built on a digest of something else (a record keyed by a hash of an action id, a
|
|
@@ -548,10 +548,10 @@ export async function pushWorldChangeset(name: string, options: PushWorldOptions
|
|
|
548
548
|
const there = worldPaths(state, join(target.instance.dirs.data, twin)).resources;
|
|
549
549
|
for (const { key, path } of await ownBlobs(twin, state)) {
|
|
550
550
|
if (!(await wanted(key, path))) continue;
|
|
551
|
-
const held = await blobs.get(
|
|
551
|
+
const held = await blobs.get(resourceBlobFile(there, key));
|
|
552
552
|
if (held !== null && blobDigest(held) === (await digestOf(path))) continue;
|
|
553
553
|
const bytes = await blobs.get(path);
|
|
554
|
-
if (bytes) await blobs.put(
|
|
554
|
+
if (bytes) await blobs.put(resourceBlobFile(there, key), bytes);
|
|
555
555
|
}
|
|
556
556
|
}
|
|
557
557
|
return;
|
package/src/served-world.ts
CHANGED
|
@@ -49,7 +49,7 @@ import { fetchFromOrigin } from './origin.ts';
|
|
|
49
49
|
import { CONSOLE_BASE, consoleRedirect, serveConsoleApart, type ConsoleMount } from './console-apart.ts';
|
|
50
50
|
import { loadWorldConfig, worldChangesetRules, writeWorldConfig } from './configs.ts';
|
|
51
51
|
import { adaptersFor, credentialPath, credentialPayloadFrom, deployTwin, deployWorld, loadWorldChecks, twinSigningSecret, materializeRoots, refreshPosture, refreshTwin, rootForControlRoot, rewrapTwinCredentials, sealTwinCredential, sealedCredentialInfo, bindLegacyCredentials, setTwinRoot, spanMs } from './root.ts';
|
|
52
|
-
import { describeActionFields } from '@volter/world-core/runtime';
|
|
52
|
+
import { describeActionFields, resourceBlobFile } from '@volter/world-core/runtime';
|
|
53
53
|
import { worldLedgers } from './changeset.ts';
|
|
54
54
|
import { clockFile, downWorld, instanceDir, isWorldOrigin, listWorlds, readWorldBootRecord, statusWorld, upWorld } from './runtime.ts';
|
|
55
55
|
import { assertChangesetRules, type WorldChangesetRules, type WorldInstance } from './schema.ts';
|
|
@@ -1155,12 +1155,20 @@ export class WorldDoors {
|
|
|
1155
1155
|
else if (to.hostname === host) out.set('location', `${to.pathname}${to.search}${to.hash}`);
|
|
1156
1156
|
else if (browser && routes && resolveTwinHost(routes.rules, to.hostname, routes.map, to.pathname, routes.claimed) && routes.sites.some(row => row.hosts.includes(to.hostname))) { const at = this.host.sitesTemplate?.(); const next = at ? siteUrlOf(at, to.hostname, browser) : null; if (next) out.set('location', `${next}${to.pathname}${to.search}${to.hash}`); }
|
|
1157
1157
|
} catch { /* left as answered */ } }
|
|
1158
|
+
// A page a site answers links another site the World serves by its hostname (www.videogame.ai's link to
|
|
1159
|
+
// cyclotron.videogame.ai): it opens where the World shows that site, as a browser redirect does above.
|
|
1160
|
+
const at = request.method !== 'HEAD' ? this.host.sitesTemplate?.() : undefined;
|
|
1161
|
+
const linked = at ? await this.inWorldLinks(answer, out, url.origin, at, undefined, browser) : null;
|
|
1162
|
+
if (linked) return linked;
|
|
1158
1163
|
return new Response(request.method === 'HEAD' ? null : answer.body, { status: answer.status, statusText: answer.statusText, headers: out });
|
|
1159
1164
|
}
|
|
1160
1165
|
// a person's browser is told in words (often the site is not there yet, or was not in a copy of the World as it was)
|
|
1161
1166
|
if ((request.headers.get('accept') ?? '').includes('text/html')) {
|
|
1162
1167
|
const shown = host.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
|
|
1163
|
-
|
|
1168
|
+
// A link here from a page in the World (a post's URL) names a site the World does not hold: the person may open
|
|
1169
|
+
// the real one, by a click of their own (an answered redirect would make the World's origin an open redirect).
|
|
1170
|
+
const real = `https://${host}${path}${url.search}`.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
|
|
1171
|
+
return new Response(`<!doctype html><meta charset="utf-8"><title>${shown}</title><body style="font:16px system-ui;margin:3em;color:#555"><p>Nothing is served at <b>${shown}</b> in this World.</p><p><a href="${real}" rel="noopener noreferrer">Open ${shown} outside the World</a></p></body>`, { status: 404, headers: { 'content-type': 'text/html; charset=utf-8' } });
|
|
1164
1172
|
}
|
|
1165
1173
|
return Response.json({ error: `no twin of ${this.served} answers ${host}` }, { status: 404 });
|
|
1166
1174
|
}
|
|
@@ -1369,9 +1377,31 @@ export class WorldDoors {
|
|
|
1369
1377
|
out.delete('set-cookie');
|
|
1370
1378
|
for (const c of setCookies) if (!/^\s*(?:__Host-)?volter_(?:world|pages|browser)=/i.test(c)) out.append('set-cookie', c);
|
|
1371
1379
|
}
|
|
1380
|
+
// a twin's page links a site this World serves (a post's URL, a Worker's custom domain) by its hostname: the
|
|
1381
|
+
// person who follows it stays in the World
|
|
1382
|
+
if (sitesAt && request.method === 'GET' && answer.status < 300) {
|
|
1383
|
+
const linked = await this.inWorldLinks(answer, out, url.origin, sitesAt, vendor);
|
|
1384
|
+
if (linked) return linked;
|
|
1385
|
+
}
|
|
1372
1386
|
return new Response(answer.body, { status: answer.status, headers: out });
|
|
1373
1387
|
}
|
|
1374
1388
|
|
|
1389
|
+
/** A page's links to sites this World serves (the hostnames its twins claim, a Worker's custom domain among them),
|
|
1390
|
+
* rewritten to where the World shows each (siteUrlOf), so a person who follows one stays in the World. The page's own
|
|
1391
|
+
* vendor's hosts (`own`) are its own to place, and a site the World does not hold keeps its real address. Null
|
|
1392
|
+
* when there is nothing to do: not HTML, compressed, or no site served. */
|
|
1393
|
+
private async inWorldLinks(answer: Response, out: Headers, origin: string, template: string, own: string | undefined, browser?: string): Promise<Response | null> {
|
|
1394
|
+
if (!answer.body || !(out.get('content-type') ?? '').includes('text/html') || out.has('content-encoding')) return null;
|
|
1395
|
+
const served = new Set((await this.claimedHosts(origin)).filter(row => row.vendor !== own).flatMap(row => row.hosts.map(h => h.toLowerCase())));
|
|
1396
|
+
if (served.size === 0) return null;
|
|
1397
|
+
const html = (await answer.text()).replace(/(href|action)="https?:\/\/([A-Za-z0-9.-]+)(?::\d+)?((?:[/?#][^"]*)?)"/g, (match, attr: string, to: string, rest: string) => {
|
|
1398
|
+
const next = served.has(to.toLowerCase()) ? siteUrlOf(template, to.toLowerCase(), browser) : null;
|
|
1399
|
+
return next ? `${attr}="${next}${rest || '/'}"` : match;
|
|
1400
|
+
});
|
|
1401
|
+
out.delete('content-length');
|
|
1402
|
+
return new Response(html, { status: answer.status, statusText: answer.statusText, headers: out });
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1375
1405
|
private linksPath(): string { return join(this.worldRoot, stateDirName(), 'links.json'); }
|
|
1376
1406
|
/** The World's links: name → the vendor origin it forwards to. */
|
|
1377
1407
|
links(): Record<string, { origin: string }> {
|
|
@@ -1906,7 +1936,7 @@ export class WorldDoors {
|
|
|
1906
1936
|
// a digest-shaped segment is lowercase, so a case-insensitive filesystem can't take unchecked bytes for it
|
|
1907
1937
|
if (key.split('/').some((seg) => /^[0-9a-fA-F]{64}$/.test(seg) && seg !== seg.toLowerCase())) return Response.json({ error: 'a digest in a blob key is lowercase hex' }, { status: 400 });
|
|
1908
1938
|
if (!this.up().services[name]) return Response.json({ error: `no twin "${name}" in ${this.served}` }, { status: 404 });
|
|
1909
|
-
const path =
|
|
1939
|
+
const path = resourceBlobFile(worldPaths(this.stateOf(name), this.controlRoot(name)).resources, key);
|
|
1910
1940
|
const store = getActiveBlobStore();
|
|
1911
1941
|
// HEAD names the blob's own digest, so a push can tell the same key holding other bytes from the same blob
|
|
1912
1942
|
if (request.method === 'HEAD') {
|