@telorun/kernel 0.58.0 → 0.60.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/dist/bundle/files-integrity.d.ts +21 -12
  2. package/dist/bundle/files-integrity.d.ts.map +1 -1
  3. package/dist/bundle/files-integrity.js +27 -14
  4. package/dist/bundle/files-integrity.js.map +1 -1
  5. package/dist/bundle/module-artifact.d.ts +134 -0
  6. package/dist/bundle/module-artifact.d.ts.map +1 -0
  7. package/dist/bundle/module-artifact.js +300 -0
  8. package/dist/bundle/module-artifact.js.map +1 -0
  9. package/dist/bundle/module-manifest.d.ts +12 -4
  10. package/dist/bundle/module-manifest.d.ts.map +1 -1
  11. package/dist/bundle/module-manifest.js +8 -4
  12. package/dist/bundle/module-manifest.js.map +1 -1
  13. package/dist/controller-loader.d.ts +3 -2
  14. package/dist/controller-loader.d.ts.map +1 -1
  15. package/dist/controller-loader.js +8 -8
  16. package/dist/controller-loader.js.map +1 -1
  17. package/dist/controller-loaders/bundle-loader.d.ts +8 -6
  18. package/dist/controller-loaders/bundle-loader.d.ts.map +1 -1
  19. package/dist/controller-loaders/bundle-loader.js +52 -13
  20. package/dist/controller-loaders/bundle-loader.js.map +1 -1
  21. package/dist/controller-loaders/npm-loader.d.ts +0 -18
  22. package/dist/controller-loaders/npm-loader.d.ts.map +1 -1
  23. package/dist/controller-loaders/npm-loader.js +3 -190
  24. package/dist/controller-loaders/npm-loader.js.map +1 -1
  25. package/dist/controllers/resource-definition/resource-definition-controller.d.ts.map +1 -1
  26. package/dist/controllers/resource-definition/resource-definition-controller.js +6 -1
  27. package/dist/controllers/resource-definition/resource-definition-controller.js.map +1 -1
  28. package/dist/directory-lock.d.ts +27 -0
  29. package/dist/directory-lock.d.ts.map +1 -0
  30. package/dist/directory-lock.js +205 -0
  31. package/dist/directory-lock.js.map +1 -0
  32. package/dist/evaluation-context.d.ts.map +1 -1
  33. package/dist/evaluation-context.js +20 -6
  34. package/dist/evaluation-context.js.map +1 -1
  35. package/dist/index.d.ts +4 -2
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +3 -1
  38. package/dist/index.js.map +1 -1
  39. package/dist/kernel.d.ts +32 -0
  40. package/dist/kernel.d.ts.map +1 -1
  41. package/dist/kernel.js +55 -1
  42. package/dist/kernel.js.map +1 -1
  43. package/dist/manifest-sources/local-manifest-cache-source.d.ts +1 -1
  44. package/dist/manifest-sources/local-manifest-cache-source.d.ts.map +1 -1
  45. package/dist/manifest-sources/local-manifest-cache-source.js +1 -1
  46. package/dist/manifest-sources/local-manifest-cache-source.js.map +1 -1
  47. package/dist/resource-context.d.ts +18 -0
  48. package/dist/resource-context.d.ts.map +1 -1
  49. package/dist/resource-context.js +72 -1
  50. package/dist/resource-context.js.map +1 -1
  51. package/dist/transports/oci/oci-client.d.ts +28 -1
  52. package/dist/transports/oci/oci-client.d.ts.map +1 -1
  53. package/dist/transports/oci/oci-client.js +24 -1
  54. package/dist/transports/oci/oci-client.js.map +1 -1
  55. package/dist/transports/oci/oci-transport.d.ts +20 -14
  56. package/dist/transports/oci/oci-transport.d.ts.map +1 -1
  57. package/dist/transports/oci/oci-transport.js +105 -46
  58. package/dist/transports/oci/oci-transport.js.map +1 -1
  59. package/dist/transports/registry-transport.d.ts +7 -2
  60. package/dist/transports/registry-transport.d.ts.map +1 -1
  61. package/dist/transports/registry-transport.js +8 -33
  62. package/dist/transports/registry-transport.js.map +1 -1
  63. package/dist/transports/transport-registry.d.ts +5 -4
  64. package/dist/transports/transport-registry.d.ts.map +1 -1
  65. package/dist/transports/transport-registry.js +4 -4
  66. package/dist/transports/transport-registry.js.map +1 -1
  67. package/dist/transports/transport.d.ts +27 -19
  68. package/dist/transports/transport.d.ts.map +1 -1
  69. package/package.json +3 -3
  70. package/src/bundle/files-integrity.ts +34 -14
  71. package/src/bundle/module-artifact.ts +375 -0
  72. package/src/bundle/module-manifest.ts +22 -6
  73. package/src/controller-loader.ts +9 -4
  74. package/src/controller-loaders/bundle-loader.ts +57 -11
  75. package/src/controller-loaders/npm-loader.ts +3 -209
  76. package/src/controllers/resource-definition/resource-definition-controller.ts +19 -0
  77. package/src/directory-lock.ts +225 -0
  78. package/src/evaluation-context.ts +17 -7
  79. package/src/index.ts +10 -2
  80. package/src/kernel.ts +66 -0
  81. package/src/manifest-sources/local-manifest-cache-source.ts +1 -1
  82. package/src/resource-context.ts +78 -1
  83. package/src/transports/oci/oci-client.ts +32 -1
  84. package/src/transports/oci/oci-transport.ts +122 -51
  85. package/src/transports/registry-transport.ts +10 -38
  86. package/src/transports/transport-registry.ts +5 -5
  87. package/src/transports/transport.ts +32 -19
package/src/index.ts CHANGED
@@ -23,11 +23,19 @@ export {
23
23
  export { makeTarGz, readTarGz, type BundleEntry } from "./bundle/tar.js";
24
24
  export {
25
25
  computeFilesIntegrity,
26
- injectFilesIntegrity,
26
+ injectLayerIndex,
27
27
  type PayloadFile,
28
28
  } from "./bundle/files-integrity.js";
29
+ export {
30
+ ModuleArtifact,
31
+ moduleArtifactFor,
32
+ moduleDirectoryFor,
33
+ hostPlatformTarget,
34
+ type MaterializedLayer,
35
+ } from "./bundle/module-artifact.js";
36
+ export { readOwnerManifest, type OwnerManifest } from "./bundle/module-manifest.js";
29
37
  export type {
30
- FetchedArtifact,
38
+ PayloadLayer,
31
39
  PublishBundle,
32
40
  PublishResult,
33
41
  PublishOptions,
package/src/kernel.ts CHANGED
@@ -49,8 +49,16 @@ import {
49
49
  writeAnalysisStamp,
50
50
  } from "./manifest-sources/analysis-stamp.js";
51
51
  import {
52
+ cachePathForCanonical,
52
53
  resolveCacheRoot,
54
+ resolveEntryDir,
53
55
  } from "./manifest-sources/local-manifest-cache-source.js";
56
+ import { readOwnerManifest } from "./bundle/module-manifest.js";
57
+ import {
58
+ moduleArtifactFor,
59
+ moduleDirectoryFor,
60
+ type ModuleArtifact,
61
+ } from "./bundle/module-artifact.js";
54
62
  import { defaultTransportRegistry } from "./transports/transport-registry.js";
55
63
  import {
56
64
  collectDeclaredEnvKeys,
@@ -131,6 +139,9 @@ export class Kernel implements IKernel {
131
139
  /** The `.telo` cache root for this load, resolved once in `load()` and
132
140
  * threaded to the validator, analysis stamp, and npm install root. */
133
141
  private _cacheRoot?: string | null;
142
+ /** Per-module artifact handles, keyed by canonical manifest source. Rebuilt on
143
+ * every `load()`; empty for a graph of purely local / manifest-only modules. */
144
+ private readonly moduleArtifacts = new Map<string, ModuleArtifact>();
134
145
  private _loadedGraph?: LoadedGraph;
135
146
  // Lifecycle state — guards boot/runTargets/teardown/invoke transitions.
136
147
  // teardown() is the only idempotent method; everything else throws on misuse.
@@ -425,6 +436,7 @@ export class Kernel implements IKernel {
425
436
  );
426
437
  }
427
438
  this._loadedGraph = analysisGraph;
439
+ this.buildModuleArtifacts(analysisGraph, manifestsDir);
428
440
  // Version reconciliation: an incompatible major mismatch is fatal (the
429
441
  // hoist override would silently run the wrong major); a same-major hoist is
430
442
  // advisory — the override already redirects every importer to the winner.
@@ -918,6 +930,60 @@ export class Kernel implements IKernel {
918
930
  return this._cacheRoot ? `${this._cacheRoot}/npm` : undefined;
919
931
  }
920
932
 
933
+ /**
934
+ * Build one {@link ModuleArtifact} per loaded module that ships a payload.
935
+ *
936
+ * Here, and not inside a controller loader, because this is the only point
937
+ * where both halves are in hand: the **pinned** ref the importer wrote
938
+ * (`requestedUrl`, integrity fragment included) and the manifest text that has
939
+ * already been verified against it. A loader sees only the canonical base URI,
940
+ * which carries no `#sha256-`, so a loader that fetched for itself would have
941
+ * to re-read the index off the cache directory — verification downgraded from
942
+ * "anchored at the importer's pin" to "trust whatever is on disk".
943
+ *
944
+ * Keyed by canonical `source`, which is what a definition's `metadata.source`
945
+ * carries, so a controller resolution can find its module's artifact.
946
+ *
947
+ * The trigger is "this manifest carries a `layers:` index", never "its source
948
+ * maps to a cache path". Those differ the moment the cache is warm:
949
+ * `LocalManifestCacheSource` serves a hit as a `file://` URL into
950
+ * `.telo/manifests/`, which no transport claims, so deriving the directory from
951
+ * `source` yields nothing and every OCI module would silently lose its artifact
952
+ * on the second and every later run — taking lazy asset materialization with it.
953
+ * The **pinned** `requestedUrl` survives a cache hit, so placement is derived
954
+ * from that, with a local `source` resolving to its own directory.
955
+ */
956
+ private buildModuleArtifacts(graph: LoadedGraph, manifestsDir: string | undefined): void {
957
+ this.moduleArtifacts.clear();
958
+ const transports = defaultTransportRegistry(this.registryUrl);
959
+ const entryDir = this._entryUrl ? resolveEntryDir(this._entryUrl) ?? "" : "";
960
+ for (const [, module] of graph.modules) {
961
+ const file = module.owner;
962
+ if (this.moduleArtifacts.has(file.source)) continue;
963
+ const artifact = moduleArtifactFor({
964
+ pinnedRef: file.requestedUrl,
965
+ layers: readOwnerManifest(file.text).layers,
966
+ moduleDir: moduleDirectoryFor(
967
+ file.requestedUrl,
968
+ file.source,
969
+ entryDir,
970
+ this.registryUrl,
971
+ manifestsDir,
972
+ ),
973
+ transports,
974
+ log: this.logging.kernelLogger(),
975
+ });
976
+ if (artifact) this.moduleArtifacts.set(file.source, artifact);
977
+ }
978
+ }
979
+
980
+ /** The artifact of the module whose manifest resolved from `source`, or
981
+ * `undefined` for a module with no fetchable payload (already on disk,
982
+ * manifest-only, or not cacheable). */
983
+ getModuleArtifact(source: string | undefined): ModuleArtifact | undefined {
984
+ return source ? this.moduleArtifacts.get(source) : undefined;
985
+ }
986
+
921
987
  /** Authored `kind` of a declared resource by name, from the static manifest
922
988
  * set. Init-order-independent (unlike `resourceInstances`), so a controller
923
989
  * resolving a `!ref <name>` sentinel before the target initializes can still
@@ -133,7 +133,7 @@ export class LocalManifestCacheSource implements ManifestSource {
133
133
  export function cachePathForCanonical(
134
134
  canonicalSource: string,
135
135
  entryDir: string,
136
- registryUrl: string,
136
+ registryUrl: string | undefined = DEFAULT_REGISTRY_URL,
137
137
  manifestsDir?: string,
138
138
  ): string | null {
139
139
  const cacheRoot = manifestsDir ?? path.join(entryDir, CACHE_SUBDIR);
@@ -20,6 +20,9 @@ import {
20
20
  type TypeRule,
21
21
  } from "@telorun/sdk";
22
22
  import { isRefSentinel } from "@telorun/templating";
23
+ import * as path from "path";
24
+ import { pathToFileURL } from "url";
25
+ import type { ModuleArtifact } from "./bundle/module-artifact.js";
23
26
  import { hostEnv } from "./host-env.js";
24
27
  import type { LoggingHost } from "./logging/logging-host.js";
25
28
  import type { ScopeConfig } from "./logging/scope-config.js";
@@ -326,7 +329,27 @@ export class ResourceContextImpl implements ResourceContext {
326
329
  describe: () => string,
327
330
  expects?: string,
328
331
  ): T {
329
- return resolveRefInstance(value, this, guard, describe, expects);
332
+ // Two things the raw resolver cannot do from a `{ moduleContext }` slice:
333
+ //
334
+ // - A `!ref` can reach a controller as the raw SENTINEL. Phase-5 injection is
335
+ // field-map-driven, and the field map does not descend into the inline
336
+ // declarations inside an `x-telo-scope` array, so a ref slot on a scoped
337
+ // resource is not an injection site. (Phase 2.5 does rewrite such a
338
+ // sentinel to `{kind, name}` when it can name a target, so the shape that
339
+ // arrives varies — both are accepted.) `ensureKindRef` is the same rescue
340
+ // the sentinel path already performs for hidden slots.
341
+ // - A scope-local name lives in the OWNING context, not the module, so a
342
+ // `with:`-scoped resource referencing a scoped sibling has to resolve in
343
+ // the same order `contextForName` and `ScopeContext.getInstance` use —
344
+ // scope-local first, module as the fallback — or CEL and `!ref` disagree
345
+ // about what a name means inside a scope.
346
+ const normalized = isRefSentinel(value) ? this.ensureKindRef(value) : value;
347
+ return resolveRefInstance(normalized, this, guard, describe, expects);
348
+ }
349
+
350
+ /** Name lookup with scope-local precedence, for {@link resolveRefInstance}. */
351
+ resolveLocalInstance(name: string): ResourceInstance | undefined {
352
+ return this.contextForName(name).resourceInstances.get(name)?.instance;
330
353
  }
331
354
 
332
355
  async run(name: string) {
@@ -553,6 +576,60 @@ export class ResourceContextImpl implements ResourceContext {
553
576
  return this.kernel.getInstallRoot();
554
577
  }
555
578
 
579
+ /** The artifact of the module whose manifest resolved from `source`. Kernel-only
580
+ * (it hands back a kernel class), reached by the resource-definition controller
581
+ * through {@link ModuleArtifactHost} rather than the SDK surface. */
582
+ getModuleArtifact(source: string | undefined): ModuleArtifact | undefined {
583
+ return this.kernel.getModuleArtifact(source);
584
+ }
585
+
586
+ /**
587
+ * Resolve a module-relative reference against the declaring module's own
588
+ * directory, materializing the layers that could carry it on first use.
589
+ *
590
+ * A URI, not a filesystem path: the SDK is cross-runtime, and a path is only
591
+ * what *this* kernel happens to return for a module whose files are local.
592
+ * An already-absolute URI (one with a scheme) passes through untouched; a bare
593
+ * absolute filesystem path is returned as a `file://` URI rather than being
594
+ * rebased onto the module directory.
595
+ */
596
+ async resolveModuleFile(relative: string): Promise<string> {
597
+ // An absolute URI names its own location; a bare absolute path is already
598
+ // resolved and must not be rebased onto the module directory.
599
+ if (/^[a-z][a-z0-9+.-]*:/i.test(relative)) return relative;
600
+ if (path.isAbsolute(relative)) return pathToFileURL(relative).href;
601
+
602
+ const source = this.moduleContext.source;
603
+ const artifact = this.kernel.getModuleArtifact(source);
604
+ if (artifact) {
605
+ // Both the `assets` layer and `common` — the sink rule puts a file the
606
+ // author did not claim via `assets:` into `common`, and a module that ships
607
+ // static files with no bundled controller has no other route to its payload.
608
+ // Fetching only assets would leave such a module resolving into an empty
609
+ // directory.
610
+ await artifact.materializeModuleFiles();
611
+ return new URL(relative, pathToFileURL(path.join(artifact.directory, "/")).href).href;
612
+ }
613
+ // No artifact means no payload to fetch. That is normal for a module already
614
+ // on disk (development) or one that ships no files — but for a module reached
615
+ // over a non-local scheme it means the artifact carries no layer index, i.e. it
616
+ // predates layers. Raise the actionable error here rather than leaving each
617
+ // caller to invent its own message from a URI it cannot open.
618
+ if (!source.startsWith("file://") && !path.isAbsolute(source)) {
619
+ throw new RuntimeError(
620
+ "ERR_MODULE_FILES_UNAVAILABLE",
621
+ `Cannot resolve '${relative}' against module '${source}': the module's artifact ` +
622
+ `carries no layer index, so its files cannot be located. It was published by an ` +
623
+ `older Telo that wrote a single-blob artifact — republish the module, or import it ` +
624
+ `from a local path during development.`,
625
+ );
626
+ }
627
+ // Local module: resolve against the manifest URL, the same rule `include:`
628
+ // and sibling imports follow.
629
+ const base = source.startsWith("file://") ? source : pathToFileURL(source).href;
630
+ return new URL(relative, base).href;
631
+ }
632
+
556
633
  on(event: string, handler: (payload?: any) => void | Promise<void>): void {
557
634
  this.kernel.on(event, handler);
558
635
  }
@@ -4,7 +4,34 @@ import { createHash } from "node:crypto";
4
4
  import { assertPublicEgress } from "../egress-guard.js";
5
5
  import { resolveDockerCredential } from "./docker-credentials.js";
6
6
 
7
- export const TELO_LAYER_MEDIA_TYPE = "application/vnd.telo.module.v1+tar";
7
+ /** The **manifest layer**: the blob carrying `telo.yaml`. The only layer located
8
+ * through the OCI manifest — its bytes are then verified against the import
9
+ * pin, and every other layer is addressed by a digest the pinned `layers:` index
10
+ * supplies. */
11
+ export const TELO_MANIFEST_LAYER_MEDIA_TYPE = "application/vnd.telo.module.manifest.v1+tar";
12
+
13
+ /**
14
+ * The pre-layers single-blob artifact: `telo.yaml` and the whole `files:` payload
15
+ * in one layer.
16
+ *
17
+ * Still read, because every module published so far carries it and the ones with
18
+ * no payload — the npm-backed majority — are perfectly usable from it: the read
19
+ * path only ever wanted `telo.yaml`, and this blob contains it. What such an
20
+ * artifact cannot supply is a `layers:` index, so a module that *does* ship a
21
+ * payload resolves its manifest and then fails at the controller with an
22
+ * actionable "republish" error. That is the intended, narrow break.
23
+ */
24
+ export const TELO_LEGACY_LAYER_MEDIA_TYPE = "application/vnd.telo.module.v1+tar";
25
+
26
+ /** A **payload layer**: one controller selector's files, the `assets` layer, or
27
+ * the `common` layer. Which is which is read from the pinned index, never from
28
+ * the descriptor — the annotations below are for `docker manifest inspect`
29
+ * legibility only and are never read back. */
30
+ export const TELO_PAYLOAD_LAYER_MEDIA_TYPE = "application/vnd.telo.module.layer.v1+tar";
31
+
32
+ export const TELO_LAYER_ROLE_ANNOTATION = "run.telo.layer.role";
33
+ export const TELO_LAYER_SELECTOR_ANNOTATION = "run.telo.layer.selector";
34
+
8
35
  export const OCI_MANIFEST_MEDIA_TYPE = "application/vnd.oci.image.manifest.v1+json";
9
36
  export const OCI_EMPTY_CONFIG_MEDIA_TYPE = "application/vnd.oci.empty.v1+json";
10
37
 
@@ -25,6 +52,10 @@ export interface OciDescriptor {
25
52
  size: number;
26
53
  data?: string;
27
54
  artifactType?: string;
55
+ /** Descriptive per-layer metadata (`run.telo.layer.*`). Written so native
56
+ * tooling can read the artifact's shape; never consumed by Telo, which takes
57
+ * role and selector from the pinned index instead. */
58
+ annotations?: Record<string, string>;
28
59
  }
29
60
 
30
61
  export interface OciManifest {
@@ -1,17 +1,23 @@
1
1
  import {
2
2
  DEFAULT_MANIFEST_FILENAME,
3
3
  IntegrityError,
4
+ selectorKey,
4
5
  sha256Base64Url,
5
6
  verifyIntegrity,
7
+ type ArtifactLayer,
6
8
  type ManifestCacheCoords,
7
9
  type ManifestSource,
8
10
  } from "@telorun/analyzer";
11
+ import { createHash } from "node:crypto";
9
12
 
10
- import { computeFilesIntegrity, injectFilesIntegrity } from "../../bundle/files-integrity.js";
13
+ import {
14
+ computeFilesIntegrity,
15
+ injectLayerIndex,
16
+ type PayloadFile,
17
+ } from "../../bundle/files-integrity.js";
11
18
  import { readOwnerManifest, type OwnerManifest } from "../../bundle/module-manifest.js";
12
19
  import { makeTarGz, readTarGz, toPayloadFiles } from "../../bundle/tar.js";
13
20
  import type {
14
- FetchedArtifact,
15
21
  PublishBundle,
16
22
  PublishOptions,
17
23
  PublishResult,
@@ -20,7 +26,12 @@ import type {
20
26
  import {
21
27
  OciClient,
22
28
  OCI_MANIFEST_MEDIA_TYPE,
23
- TELO_LAYER_MEDIA_TYPE,
29
+ TELO_LAYER_ROLE_ANNOTATION,
30
+ TELO_LEGACY_LAYER_MEDIA_TYPE,
31
+ TELO_LAYER_SELECTOR_ANNOTATION,
32
+ TELO_MANIFEST_LAYER_MEDIA_TYPE,
33
+ TELO_PAYLOAD_LAYER_MEDIA_TYPE,
34
+ type OciDescriptor,
24
35
  type OciManifest,
25
36
  } from "./oci-client.js";
26
37
  import {
@@ -31,23 +42,44 @@ import {
31
42
  withRefVersion,
32
43
  } from "./oci-ref.js";
33
44
 
34
- /** Pull the module blob, returning its extracted entries and the `telo.yaml`
35
- * bytes, verified against the ref's inline hash and its own `filesIntegrity`. */
36
- async function pullVerified(ref: string): Promise<FetchedArtifact> {
45
+ /**
46
+ * Pull only the **manifest layer** and return its verified `telo.yaml` text.
47
+ *
48
+ * This is the one place the OCI manifest is load-bearing: it is fetched by a
49
+ * reference that is usually a mutable tag and Telo never hashes it, so it is used
50
+ * solely to locate the blob carrying `telo.yaml`. Those bytes are then checked
51
+ * against the import's inline pin, which is what makes the rest of the artifact
52
+ * safe to address from the `layers:` index inside them — tampering with the OCI
53
+ * manifest can only change *which* blob is offered as the manifest, and a
54
+ * substituted one fails the pin here.
55
+ *
56
+ * Payload layers are never pulled on this path, so reading a manifest no longer
57
+ * downloads a payload it discards.
58
+ *
59
+ * A pre-layers single-blob artifact is still read here: it contains `telo.yaml`
60
+ * too, which is all this path wants, so every already-published module keeps
61
+ * resolving. What it cannot offer is a `layers:` index — so a module that ships a
62
+ * payload gets a clear "republish" failure at the controller instead, while the
63
+ * npm-backed majority, which ships none, is unaffected.
64
+ */
65
+ async function pullManifestLayer(ref: string): Promise<string> {
37
66
  const { host, repo, reference, integrity } = parseOciRef(ref);
38
67
  const client = new OciClient(host, repo);
39
68
  const manifest = await client.pullManifest(reference);
40
69
  const layer =
41
- manifest.layers.find((l) => l.mediaType === TELO_LAYER_MEDIA_TYPE) ?? manifest.layers[0];
70
+ manifest.layers.find((l) => l.mediaType === TELO_MANIFEST_LAYER_MEDIA_TYPE) ??
71
+ manifest.layers.find((l) => l.mediaType === TELO_LEGACY_LAYER_MEDIA_TYPE) ??
72
+ manifest.layers[0];
42
73
  if (!layer) {
43
74
  throw new Error(`OCI artifact ${ref} has no layers`);
44
75
  }
45
- const tar = await client.pullBlob(layer.digest);
46
- const entries = await readTarGz(tar);
76
+ const entries = await readTarGz(await client.pullBlob(layer.digest));
47
77
 
48
78
  const teloEntry = entries.find((e) => e.name === DEFAULT_MANIFEST_FILENAME);
49
79
  if (!teloEntry) {
50
- throw new Error(`OCI artifact ${ref} blob does not contain ${DEFAULT_MANIFEST_FILENAME}`);
80
+ throw new Error(
81
+ `OCI artifact ${ref} manifest layer does not contain ${DEFAULT_MANIFEST_FILENAME}`,
82
+ );
51
83
  }
52
84
  const manifestText =
53
85
  typeof teloEntry.content === "string" ? teloEntry.content : teloEntry.content.toString("utf-8");
@@ -56,28 +88,18 @@ async function pullVerified(ref: string): Promise<FetchedArtifact> {
56
88
  if (integrity) {
57
89
  await verifyIntegrity(new TextEncoder().encode(manifestText), integrity, ref);
58
90
  }
59
-
60
- const files = toPayloadFiles(entries);
61
-
62
- const { filesIntegrity } = readOwnerManifest(manifestText);
63
- if (filesIntegrity) {
64
- const actual = await computeFilesIntegrity(files);
65
- if (actual !== filesIntegrity) {
66
- throw new IntegrityError(
67
- `Integrity check failed for ${ref}: filesIntegrity expected ${filesIntegrity}, ` +
68
- `got ${actual}. The payload does not match the recorded hash.`,
69
- );
70
- }
71
- }
72
-
73
- return { manifest: manifestText, files };
91
+ return manifestText;
74
92
  }
75
93
 
76
94
  /**
77
95
  * OCI transport: `oci://host/repo@reference` modules on any OCI distribution
78
96
  * registry (GHCR / ECR / Docker Hub / Harbor), over a hand-rolled minimal
79
- * client. A module is a single artifact one tar blob carrying `telo.yaml`
80
- * and the `files:` payload pushed under a standard OCI artifact manifest.
97
+ * client. A module is one OCI artifact whose layers are the module's layers —
98
+ * `telo.yaml` in its own blob, then one blob per controller selector, plus the
99
+ * `assets` and `common` blobs — so a client fetches only what it needs. A flat
100
+ * layer list, not an image index: the manifest and asset layers are
101
+ * platform-neutral, so a manifest list would duplicate them per platform entry
102
+ * and add a round trip for a selection made from the pinned index anyway.
81
103
  *
82
104
  * Not browser-reachable (token handshake, Docker credentials, tar extraction),
83
105
  * so its resolution `source` is Node-only; the editor resolves `oci://` imports
@@ -90,7 +112,7 @@ export class OciTransport implements Transport {
90
112
  this.source = {
91
113
  supports: (url) => this.supports(url),
92
114
  read: async (url) => {
93
- const { manifest } = await pullVerified(url);
115
+ const manifest = await pullManifestLayer(url);
94
116
  const { host, repo, reference } = parseOciRef(url);
95
117
  return { text: manifest, source: `${OCI_SCHEME}${host}/${repo}@${reference}` };
96
118
  },
@@ -159,24 +181,38 @@ export class OciTransport implements Transport {
159
181
  return new OciClient(host, repo).headManifest(reference);
160
182
  }
161
183
 
162
- async fetchArtifact(ref: string): Promise<FetchedArtifact> {
163
- return pullVerified(ref);
184
+ /** Pull one payload layer by the `blob` digest the pinned index supplies. The
185
+ * OCI layer list is not consulted — a digest addresses a blob directly, so a
186
+ * republish that reorders layers is simply invisible here rather than a
187
+ * failure. Content verification is the artifact handle's, which holds the
188
+ * expected `integrity`. */
189
+ async fetchLayer(ref: string, blobDigest: string): Promise<PayloadFile[]> {
190
+ const { host, repo } = parseOciRef(ref);
191
+ const tar = await new OciClient(host, repo).pullBlob(blobDigest);
192
+ // Verify the transfer against the digest that addressed it. A registry is
193
+ // not trusted to return the blob that was asked for, and this is the only
194
+ // place the pushed bytes exist — the content digest checked after extraction
195
+ // covers the file set, not the archive that carried it.
196
+ const actual = `sha256:${createHash("sha256").update(tar).digest("hex")}`;
197
+ if (actual !== blobDigest) {
198
+ throw new IntegrityError(
199
+ `Blob digest mismatch fetching a layer of ${ref}: requested ${blobDigest}, ` +
200
+ `received ${actual}. The registry returned different bytes than were addressed.`,
201
+ );
202
+ }
203
+ return toPayloadFiles(await readTarGz(tar));
164
204
  }
165
205
 
166
206
  /** Hashes the **UTF-8 encoding of the extracted `telo.yaml`**, which is what
167
- * `pullVerified` checks an inline `#sha256-…` pin against on the read path.
207
+ * `pullManifestLayer` checks an inline `#sha256-…` pin against on the read
208
+ * path.
168
209
  *
169
- * Cost note: a module is one tar blob, so there is no way to read `telo.yaml`
170
- * without pulling the whole artifact including any `files:` payload and
171
- * this path is deliberately uncached (a pin must hash what is published
172
- * *now*, not a cached copy). Two consequences for callers: pinning N imports
173
- * costs N full artifact pulls, and `pullVerified` also re-checks the
174
- * dependency's `filesIntegrity`, so a corrupt *payload* upstream surfaces
175
- * here as a pinning failure rather than a payload error. Both are acceptable
176
- * for publish-time pinning, where correctness beats latency and refusing to
177
- * pin against a corrupt dependency is the right outcome. */
210
+ * Deliberately uncached a pin must hash what is published *now*, not a
211
+ * cached copy but since `telo.yaml` is its own layer this costs one small
212
+ * blob per import rather than a full artifact pull, and a corrupt payload
213
+ * upstream no longer surfaces here as a pinning failure. */
178
214
  async manifestHash(ref: string): Promise<string> {
179
- const { manifest } = await pullVerified(ref);
215
+ const manifest = await pullManifestLayer(ref);
180
216
  return `sha256-${await sha256Base64Url(new TextEncoder().encode(manifest))}`;
181
217
  }
182
218
 
@@ -221,27 +257,62 @@ export class OciTransport implements Transport {
221
257
  );
222
258
  }
223
259
  const tag = identity.version;
260
+ const client = new OciClient(host, repo);
224
261
 
225
- // Pin the payload, then pack telo.yaml + files into the single module blob.
226
- let manifestText = bundle.manifest;
227
- if (bundle.files.length > 0) {
228
- manifestText = injectFilesIntegrity(manifestText, await computeFilesIntegrity(bundle.files));
262
+ // Push every payload layer first, collecting the digests that address them.
263
+ // This ordering is what keeps the index non-circular: the manifest layer is
264
+ // pushed last and names only the layers pushed before it, never itself.
265
+ const payloadDescriptors: OciDescriptor[] = [];
266
+ const index: ArtifactLayer[] = [];
267
+ for (const layer of bundle.layers) {
268
+ if (layer.files.length === 0) continue;
269
+ const tar = await makeTarGz(
270
+ layer.files.map((f) => ({ name: f.name, content: Buffer.from(f.content) })),
271
+ );
272
+ const blob = await client.pushBlob(tar);
273
+ payloadDescriptors.push({
274
+ mediaType: TELO_PAYLOAD_LAYER_MEDIA_TYPE,
275
+ digest: blob,
276
+ size: tar.length,
277
+ annotations: {
278
+ [TELO_LAYER_ROLE_ANNOTATION]: layer.role,
279
+ ...(layer.selector
280
+ ? { [TELO_LAYER_SELECTOR_ANNOTATION]: selectorKey(layer.selector) }
281
+ : {}),
282
+ },
283
+ });
284
+ index.push({
285
+ role: layer.role,
286
+ ...(layer.selector ? { selector: layer.selector } : {}),
287
+ blob,
288
+ integrity: await computeFilesIntegrity(layer.files),
289
+ });
229
290
  }
230
- const tar = await makeTarGz([
291
+
292
+ // Inject the index, then push telo.yaml as its own layer so a manifest read
293
+ // never has to pull a payload.
294
+ const manifestText =
295
+ index.length > 0 ? injectLayerIndex(bundle.manifest, index) : bundle.manifest;
296
+ const manifestTar = await makeTarGz([
231
297
  { name: DEFAULT_MANIFEST_FILENAME, content: manifestText },
232
- ...bundle.files.map((f) => ({ name: f.name, content: Buffer.from(f.content) })),
233
298
  ]);
299
+ const manifestBlob = await client.pushBlob(manifestTar);
234
300
 
235
- const client = new OciClient(host, repo);
236
- const layerDigest = await client.pushBlob(tar);
237
301
  const config = await client.pushEmptyConfig();
238
302
  const annotations = OciTransport.annotationsFor(identity);
239
303
  const manifest: OciManifest = {
240
304
  schemaVersion: 2,
241
305
  mediaType: OCI_MANIFEST_MEDIA_TYPE,
242
- artifactType: TELO_LAYER_MEDIA_TYPE,
306
+ artifactType: TELO_MANIFEST_LAYER_MEDIA_TYPE,
243
307
  config,
244
- layers: [{ mediaType: TELO_LAYER_MEDIA_TYPE, digest: layerDigest, size: tar.length }],
308
+ layers: [
309
+ {
310
+ mediaType: TELO_MANIFEST_LAYER_MEDIA_TYPE,
311
+ digest: manifestBlob,
312
+ size: manifestTar.length,
313
+ },
314
+ ...payloadDescriptors,
315
+ ],
245
316
  ...(Object.keys(annotations).length > 0 ? { annotations } : {}),
246
317
  };
247
318
  await client.pushManifest(tag, manifest);
@@ -1,7 +1,6 @@
1
1
  import {
2
2
  DEFAULT_MANIFEST_FILENAME,
3
3
  HttpSource,
4
- IntegrityError,
5
4
  RegistrySource,
6
5
  isRegistryRef,
7
6
  parseModuleRef,
@@ -15,12 +14,9 @@ import {
15
14
  import { fetchOrThrow } from "@telorun/sdk";
16
15
  import { createHash } from "crypto";
17
16
 
18
- import { computeFilesIntegrity } from "../bundle/files-integrity.js";
19
- import { readOwnerManifest } from "../bundle/module-manifest.js";
20
- import { readTarGz, toPayloadFiles } from "../bundle/tar.js";
17
+ import type { PayloadFile } from "../bundle/files-integrity.js";
21
18
  import { assertPublicEgress } from "./egress-guard.js";
22
19
  import type {
23
- FetchedArtifact,
24
20
  PublishBundle,
25
21
  PublishOptions,
26
22
  PublishResult,
@@ -272,39 +268,15 @@ export class RegistryTransport implements Transport {
272
268
  return `sha256-${await sha256Base64Url(bytes)}`;
273
269
  }
274
270
 
275
- async fetchArtifact(ref: string): Promise<FetchedArtifact> {
276
- // `read` verifies the manifest bytes against the inline `#sha256-...` hash.
277
- const { text: manifest, source } = await this.source.read(ref);
278
- const meta = readOwnerManifest(manifest);
279
- if (!meta.declaresFiles) return { manifest, files: [] };
280
-
281
- // The payload rides beside the manifest as `module.tar.gz`.
282
- const tarUrl = source.replace(/\/telo\.yaml$/, "/module.tar.gz");
283
- await assertPublicEgress(tarUrl);
284
- const res = await fetchOrThrow(tarUrl, undefined, {
285
- operation: "Module payload download",
286
- setting: "--registry / TELO_REGISTRY",
287
- });
288
- if (!res.ok) {
289
- throw new Error(`could not fetch bundle ${tarUrl}: ${res.status} ${res.statusText}`);
290
- }
291
- const files = toPayloadFiles(await readTarGz(Buffer.from(await res.arrayBuffer())));
292
-
293
- // Verify the payload against the manifest's `filesIntegrity` before handing
294
- // it back — a mismatch is terminal (a tampered bundle must never be used).
295
- // The manifest that carries the hash is itself pinned by the inline hash.
296
- if (meta.filesIntegrity) {
297
- const actual = await computeFilesIntegrity(files);
298
- if (actual !== meta.filesIntegrity) {
299
- throw new IntegrityError(
300
- `Integrity check failed for bundle ${tarUrl}: filesIntegrity expected ` +
301
- `${meta.filesIntegrity}, got ${actual}. The payload does not match the recorded ` +
302
- `hash — the module may have been tampered with or republished.`,
303
- );
304
- }
305
- }
306
-
307
- return { manifest, files };
271
+ /** Layered artifacts are an OCI concept, and the registry origin is read-only
272
+ * — nothing has ever published a payload here, so there is no layer to pull.
273
+ * A module reached over the registry is manifest-only, and its controllers
274
+ * come from npm. */
275
+ async fetchLayer(ref: string, blobDigest: string): Promise<PayloadFile[]> {
276
+ throw new Error(
277
+ `Cannot fetch layer ${blobDigest} of ${ref}: the Telo registry serves manifests only. ` +
278
+ `A module with a bundled payload is published as an OCI artifact (oci://host/repo).`,
279
+ );
308
280
  }
309
281
 
310
282
  async publish(
@@ -1,9 +1,9 @@
1
1
  import type { ManifestCacheCoords, ManifestSource } from "@telorun/analyzer";
2
2
 
3
+ import type { PayloadFile } from "../bundle/files-integrity.js";
3
4
  import { OciTransport } from "./oci/oci-transport.js";
4
5
  import { RegistryTransport } from "./registry-transport.js";
5
6
  import type {
6
- FetchedArtifact,
7
7
  PublishBundle,
8
8
  PublishOptions,
9
9
  PublishResult,
@@ -41,10 +41,10 @@ export class TransportRegistry {
41
41
  return this.require(ref).listVersions(ref);
42
42
  }
43
43
 
44
- /** Full artifact for `ref` via its owning transport. Throws when no transport
45
- * owns the ref. */
46
- fetchArtifact(ref: string): Promise<FetchedArtifact> {
47
- return this.require(ref).fetchArtifact(ref);
44
+ /** One payload layer of `ref`, addressed by the `blob` digest its pinned index
45
+ * entry carries. Throws when no transport owns the ref. */
46
+ fetchLayer(ref: string, blobDigest: string): Promise<PayloadFile[]> {
47
+ return this.require(ref).fetchLayer(ref, blobDigest);
48
48
  }
49
49
 
50
50
  /** Cheap content-identity digest for `ref` via its owning transport; `null`