@telorun/kernel 0.59.0 → 0.61.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 (120) 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/controllers/resource-definition/resource-inherited-controller.d.ts.map +1 -1
  29. package/dist/controllers/resource-definition/resource-inherited-controller.js +57 -10
  30. package/dist/controllers/resource-definition/resource-inherited-controller.js.map +1 -1
  31. package/dist/controllers/type/json-schema-controller.d.ts +8 -0
  32. package/dist/controllers/type/json-schema-controller.d.ts.map +1 -0
  33. package/dist/controllers/type/json-schema-controller.js +91 -0
  34. package/dist/controllers/type/json-schema-controller.js.map +1 -0
  35. package/dist/directory-lock.d.ts +27 -0
  36. package/dist/directory-lock.d.ts.map +1 -0
  37. package/dist/directory-lock.js +205 -0
  38. package/dist/directory-lock.js.map +1 -0
  39. package/dist/evaluation-context.d.ts +5 -0
  40. package/dist/evaluation-context.d.ts.map +1 -1
  41. package/dist/evaluation-context.js +63 -33
  42. package/dist/evaluation-context.js.map +1 -1
  43. package/dist/index.d.ts +5 -2
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js +4 -1
  46. package/dist/index.js.map +1 -1
  47. package/dist/init-failure-diagnostics.d.ts +61 -0
  48. package/dist/init-failure-diagnostics.d.ts.map +1 -0
  49. package/dist/init-failure-diagnostics.js +141 -0
  50. package/dist/init-failure-diagnostics.js.map +1 -0
  51. package/dist/invocation-contract-binding.d.ts +105 -0
  52. package/dist/invocation-contract-binding.d.ts.map +1 -0
  53. package/dist/invocation-contract-binding.js +296 -0
  54. package/dist/invocation-contract-binding.js.map +1 -0
  55. package/dist/kernel.d.ts +49 -0
  56. package/dist/kernel.d.ts.map +1 -1
  57. package/dist/kernel.js +117 -6
  58. package/dist/kernel.js.map +1 -1
  59. package/dist/manifest-sources/local-manifest-cache-source.d.ts +1 -1
  60. package/dist/manifest-sources/local-manifest-cache-source.d.ts.map +1 -1
  61. package/dist/manifest-sources/local-manifest-cache-source.js +1 -1
  62. package/dist/manifest-sources/local-manifest-cache-source.js.map +1 -1
  63. package/dist/module-context.d.ts.map +1 -1
  64. package/dist/module-context.js +21 -0
  65. package/dist/module-context.js.map +1 -1
  66. package/dist/resource-context.d.ts +61 -0
  67. package/dist/resource-context.d.ts.map +1 -1
  68. package/dist/resource-context.js +131 -0
  69. package/dist/resource-context.js.map +1 -1
  70. package/dist/schema-compiled-values.d.ts +9 -1
  71. package/dist/schema-compiled-values.d.ts.map +1 -1
  72. package/dist/schema-compiled-values.js +55 -16
  73. package/dist/schema-compiled-values.js.map +1 -1
  74. package/dist/schema-validator.d.ts.map +1 -1
  75. package/dist/schema-validator.js +15 -1
  76. package/dist/schema-validator.js.map +1 -1
  77. package/dist/transports/oci/oci-client.d.ts +28 -1
  78. package/dist/transports/oci/oci-client.d.ts.map +1 -1
  79. package/dist/transports/oci/oci-client.js +24 -1
  80. package/dist/transports/oci/oci-client.js.map +1 -1
  81. package/dist/transports/oci/oci-transport.d.ts +20 -14
  82. package/dist/transports/oci/oci-transport.d.ts.map +1 -1
  83. package/dist/transports/oci/oci-transport.js +105 -46
  84. package/dist/transports/oci/oci-transport.js.map +1 -1
  85. package/dist/transports/registry-transport.d.ts +7 -2
  86. package/dist/transports/registry-transport.d.ts.map +1 -1
  87. package/dist/transports/registry-transport.js +8 -33
  88. package/dist/transports/registry-transport.js.map +1 -1
  89. package/dist/transports/transport-registry.d.ts +5 -4
  90. package/dist/transports/transport-registry.d.ts.map +1 -1
  91. package/dist/transports/transport-registry.js +4 -4
  92. package/dist/transports/transport-registry.js.map +1 -1
  93. package/dist/transports/transport.d.ts +27 -19
  94. package/dist/transports/transport.d.ts.map +1 -1
  95. package/package.json +3 -3
  96. package/src/bundle/files-integrity.ts +34 -14
  97. package/src/bundle/module-artifact.ts +375 -0
  98. package/src/bundle/module-manifest.ts +22 -6
  99. package/src/controller-loader.ts +9 -4
  100. package/src/controller-loaders/bundle-loader.ts +57 -11
  101. package/src/controller-loaders/npm-loader.ts +3 -209
  102. package/src/controllers/resource-definition/resource-definition-controller.ts +19 -0
  103. package/src/controllers/resource-definition/resource-inherited-controller.ts +69 -10
  104. package/src/controllers/type/json-schema-controller.ts +114 -0
  105. package/src/directory-lock.ts +225 -0
  106. package/src/evaluation-context.ts +81 -32
  107. package/src/index.ts +11 -2
  108. package/src/init-failure-diagnostics.ts +169 -0
  109. package/src/invocation-contract-binding.ts +392 -0
  110. package/src/kernel.ts +149 -6
  111. package/src/manifest-sources/local-manifest-cache-source.ts +1 -1
  112. package/src/module-context.ts +27 -0
  113. package/src/resource-context.ts +138 -0
  114. package/src/schema-compiled-values.ts +55 -15
  115. package/src/schema-validator.ts +15 -1
  116. package/src/transports/oci/oci-client.ts +32 -1
  117. package/src/transports/oci/oci-transport.ts +122 -51
  118. package/src/transports/registry-transport.ts +10 -38
  119. package/src/transports/transport-registry.ts +5 -5
  120. package/src/transports/transport.ts +32 -19
@@ -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";
@@ -171,6 +174,87 @@ export class ResourceContextImpl implements ResourceContext {
171
174
  return this.validator.getTypeRules(name);
172
175
  }
173
176
 
177
+ /** The JSON Schema behind a type field, resolved the same four ways
178
+ * {@link createTypeValidator} resolves it (named ref, `{kind, name}` ref
179
+ * object, inline `{kind, schema}`, raw schema), and then followed through a
180
+ * bare `telo://<module>/<Type>` `$ref` to the schema that type registered.
181
+ *
182
+ * Following the `$ref` matters because AJV resolves cross-schema references
183
+ * against its own registry at compile time, while the kernel's registry is
184
+ * what actually holds these — a `Type.JsonSchema` registers under the
185
+ * canonical URI as a *key*, which AJV does not treat as a resolvable id. A
186
+ * definition whose whole contract is `{ $ref: "telo://Self/TokenSet" }` (the
187
+ * sanctioned way to declare a shape once and reference it from several kinds)
188
+ * would otherwise be uncompilable at dispatch. Resolving here means AJV is
189
+ * handed the real schema and never has to resolve the reference at all.
190
+ *
191
+ * Contract binding needs the schema rather than just a compiled validator
192
+ * anyway, for the decisions a validator cannot answer: which properties carry
193
+ * `x-telo-stream` and must be exempt from the walk, and which paths a
194
+ * `default:` can be written to. Returns undefined when the reference resolves
195
+ * to nothing. */
196
+ resolveTypeSchema(typeRef: unknown): Record<string, any> | undefined {
197
+ return this.followTypeAlias(this.readTypeSchema(typeRef), new Set());
198
+ }
199
+
200
+ private readTypeSchema(typeRef: unknown): Record<string, any> | undefined {
201
+ if (!typeRef) return undefined;
202
+ if (typeof typeRef === "string") return this.validator.getSchema(typeRef) as any;
203
+ if (typeof typeRef !== "object") return undefined;
204
+ const ref = typeRef as Record<string, any>;
205
+ if (ref.schema && typeof ref.schema === "object") return ref.schema;
206
+ if (typeof ref.name === "string") return this.validator.getSchema(ref.name) as any;
207
+ if (ref.type || ref.properties || ref.$ref) return ref;
208
+ return undefined;
209
+ }
210
+
211
+ /**
212
+ * Follow a schema that is nothing but a `$ref` to a registered type, so the
213
+ * schema-level questions (which properties are streams, which paths carry a
214
+ * default) are asked of the real shape rather than of an alias.
215
+ *
216
+ * Only the whole-document alias form is followed, and only to READ it — the
217
+ * schema handed to AJV keeps its `$ref`s intact, because AJV resolves them
218
+ * itself against the registered ids and each type stays its own document with
219
+ * its own `$defs`. Inlining instead would move a `$ref: "#/$defs/X"` out of the
220
+ * document that defines `$defs.X`.
221
+ *
222
+ * `seen` guards a cycle two mutually-referencing types would otherwise spin on.
223
+ * A `$ref` alongside other keywords is left alone: that is a composition, not
224
+ * an alias.
225
+ */
226
+ private followTypeAlias(
227
+ schema: Record<string, any> | undefined,
228
+ seen: Set<string>,
229
+ ): Record<string, any> | undefined {
230
+ let current = schema;
231
+ while (
232
+ current &&
233
+ typeof current.$ref === "string" &&
234
+ Object.keys(current).length === 1 &&
235
+ !seen.has(current.$ref)
236
+ ) {
237
+ seen.add(current.$ref);
238
+ const target = this.validator.getSchema(current.$ref) as Record<string, any> | undefined;
239
+ if (!target) return current;
240
+ current = target;
241
+ }
242
+ return current;
243
+ }
244
+
245
+ /** Compile `schema` but compose the CEL `rules:` registered under `name`.
246
+ *
247
+ * A named type's rules are its business invariants, and they are reachable
248
+ * only through the name. {@link createTypeValidator} composes them when it is
249
+ * handed a bare name, but a caller that must adjust the schema first — the
250
+ * contract binding, which strips `x-telo-stream` properties before validating
251
+ * — would otherwise have to choose between the adjustment and the rules. */
252
+ createTypeValidatorWithRules(name: string | undefined, schema: Record<string, any>) {
253
+ const base = this.validator.compile(schema);
254
+ const rules = name ? this.validator.getTypeRules(name) : undefined;
255
+ return rules && rules.length > 0 ? this.validator.composeWithRules(base, name!, rules) : base;
256
+ }
257
+
174
258
  createTypeValidator(typeRef: string | Record<string, any> | undefined) {
175
259
  if (!typeRef) return new NoopValidator();
176
260
 
@@ -573,6 +657,60 @@ export class ResourceContextImpl implements ResourceContext {
573
657
  return this.kernel.getInstallRoot();
574
658
  }
575
659
 
660
+ /** The artifact of the module whose manifest resolved from `source`. Kernel-only
661
+ * (it hands back a kernel class), reached by the resource-definition controller
662
+ * through {@link ModuleArtifactHost} rather than the SDK surface. */
663
+ getModuleArtifact(source: string | undefined): ModuleArtifact | undefined {
664
+ return this.kernel.getModuleArtifact(source);
665
+ }
666
+
667
+ /**
668
+ * Resolve a module-relative reference against the declaring module's own
669
+ * directory, materializing the layers that could carry it on first use.
670
+ *
671
+ * A URI, not a filesystem path: the SDK is cross-runtime, and a path is only
672
+ * what *this* kernel happens to return for a module whose files are local.
673
+ * An already-absolute URI (one with a scheme) passes through untouched; a bare
674
+ * absolute filesystem path is returned as a `file://` URI rather than being
675
+ * rebased onto the module directory.
676
+ */
677
+ async resolveModuleFile(relative: string): Promise<string> {
678
+ // An absolute URI names its own location; a bare absolute path is already
679
+ // resolved and must not be rebased onto the module directory.
680
+ if (/^[a-z][a-z0-9+.-]*:/i.test(relative)) return relative;
681
+ if (path.isAbsolute(relative)) return pathToFileURL(relative).href;
682
+
683
+ const source = this.moduleContext.source;
684
+ const artifact = this.kernel.getModuleArtifact(source);
685
+ if (artifact) {
686
+ // Both the `assets` layer and `common` — the sink rule puts a file the
687
+ // author did not claim via `assets:` into `common`, and a module that ships
688
+ // static files with no bundled controller has no other route to its payload.
689
+ // Fetching only assets would leave such a module resolving into an empty
690
+ // directory.
691
+ await artifact.materializeModuleFiles();
692
+ return new URL(relative, pathToFileURL(path.join(artifact.directory, "/")).href).href;
693
+ }
694
+ // No artifact means no payload to fetch. That is normal for a module already
695
+ // on disk (development) or one that ships no files — but for a module reached
696
+ // over a non-local scheme it means the artifact carries no layer index, i.e. it
697
+ // predates layers. Raise the actionable error here rather than leaving each
698
+ // caller to invent its own message from a URI it cannot open.
699
+ if (!source.startsWith("file://") && !path.isAbsolute(source)) {
700
+ throw new RuntimeError(
701
+ "ERR_MODULE_FILES_UNAVAILABLE",
702
+ `Cannot resolve '${relative}' against module '${source}': the module's artifact ` +
703
+ `carries no layer index, so its files cannot be located. It was published by an ` +
704
+ `older Telo that wrote a single-blob artifact — republish the module, or import it ` +
705
+ `from a local path during development.`,
706
+ );
707
+ }
708
+ // Local module: resolve against the manifest URL, the same rule `include:`
709
+ // and sibling imports follow.
710
+ const base = source.startsWith("file://") ? source : pathToFileURL(source).href;
711
+ return new URL(relative, base).href;
712
+ }
713
+
576
714
  on(event: string, handler: (payload?: any) => void | Promise<void>): void {
577
715
  this.kernel.on(event, handler);
578
716
  }
@@ -65,27 +65,67 @@ function collectSchemaProperties(
65
65
  * Template strings were compiled from YAML at load time; this restores a shape
66
66
  * that AJV can validate without evaluating expressions. When no schema is
67
67
  * supplied every compiled value collapses to `""` (the `default` branch of
68
- * `placeholderForSchema`), matching the schema-unaware strip. */
68
+ * `placeholderForSchema`), matching the schema-unaware strip.
69
+ *
70
+ * The walk stops short of anything that is not plain config, mirroring
71
+ * `buildResolvedProperties`: by the time a resource reaches validation its ref
72
+ * slots may already hold LIVE resource instances (a template passing the
73
+ * caller's client down with `client: !cel "self.client"` hands the child the
74
+ * injected instance, not a ref), and a controller's object graph is
75
+ * arbitrarily deep and routinely cyclic — walking one overflows the stack
76
+ * instead of producing a diagnostic, and there is nothing inside it to strip. */
69
77
  export function stripCompiledValues(
70
78
  v: unknown,
71
79
  schema: Record<string, unknown> = {},
72
80
  rootSchema?: Record<string, unknown>,
73
81
  ): unknown {
74
82
  const root = rootSchema ?? schema;
75
- const resolved = resolveSchemaRef(schema, root);
83
+ // Ancestors on the current path, so a genuine cycle stops while a sub-object
84
+ // that merely appears twice is still stripped both times.
85
+ const ancestors = new Set<object>();
76
86
 
77
- if (isCompiledValue(v)) return placeholderForSchema(resolved);
78
- if (Array.isArray(v)) {
79
- const itemSchema = resolveSchemaRef((resolved.items ?? {}) as Record<string, unknown>, root);
80
- return v.map((item) => stripCompiledValues(item, itemSchema, root));
81
- }
82
- if (v !== null && typeof v === "object") {
83
- const props = collectSchemaProperties(resolved);
84
- const out: Record<string, unknown> = {};
85
- for (const [k, val] of Object.entries(v as Record<string, unknown>)) {
86
- out[k] = stripCompiledValues(val, props[k] ?? {}, root);
87
+ const walk = (value: unknown, nodeSchema: Record<string, unknown>): unknown => {
88
+ const resolved = resolveSchemaRef(nodeSchema, root);
89
+
90
+ if (isCompiledValue(value)) return placeholderForSchema(resolved);
91
+ // A slot the schema declares as a reference is never config: it holds a
92
+ // `{kind, name}` ref or the live instance Phase 5 replaced it with, and the
93
+ // schema declares no shape to validate against either way.
94
+ if (resolved["x-telo-ref"] !== undefined) return value;
95
+
96
+ if (Array.isArray(value)) {
97
+ const itemSchema = resolveSchemaRef((resolved.items ?? {}) as Record<string, unknown>, root);
98
+ return walkGuarded(value, () => value.map((item) => walk(item, itemSchema)));
87
99
  }
88
- return out;
89
- }
90
- return v;
100
+ if (value !== null && typeof value === "object") {
101
+ // A class instance (a client, a pool, a stream) carries no CompiledValues
102
+ // and is not described by the schema — copying it is pure risk.
103
+ const proto = Object.getPrototypeOf(value);
104
+ if (proto !== Object.prototype && proto !== null) return value;
105
+
106
+ const props = collectSchemaProperties(resolved);
107
+ return walkGuarded(value, () => {
108
+ const out: Record<string, unknown> = {};
109
+ for (const [k, val] of Object.entries(value as Record<string, unknown>)) {
110
+ out[k] = walk(val, props[k] ?? {});
111
+ }
112
+ return out;
113
+ });
114
+ }
115
+ return value;
116
+ };
117
+
118
+ /** Runs `fn` with `node` marked as an ancestor; a node already on the path is
119
+ * a cycle and is returned as-is rather than recursed into. */
120
+ const walkGuarded = (node: object, fn: () => unknown): unknown => {
121
+ if (ancestors.has(node)) return node;
122
+ ancestors.add(node);
123
+ try {
124
+ return fn();
125
+ } finally {
126
+ ancestors.delete(node);
127
+ }
128
+ };
129
+
130
+ return walk(v, schema);
91
131
  }
@@ -8,6 +8,20 @@ import * as fs from "node:fs";
8
8
  import { createRequire } from "node:module";
9
9
  import * as path from "node:path";
10
10
  import { formatAjvErrors } from "./manifest-schemas.js";
11
+
12
+ /** Render a value for an error message without ever throwing.
13
+ *
14
+ * `JSON.stringify` refuses BigInt, and CEL evaluates an integer literal to one —
15
+ * so serializing the offending data threw from inside the message template and
16
+ * the thrown stringify error REPLACED the validation failure. The author was
17
+ * told "cannot serialize BigInt" instead of which field was wrong. */
18
+ function describeValue(data: unknown): string {
19
+ try {
20
+ return JSON.stringify(data, (_k, v) => (typeof v === "bigint" ? `${v}` : v)) ?? String(data);
21
+ } catch {
22
+ return String(data);
23
+ }
24
+ }
11
25
  import {
12
26
  EXACT_TEMPLATE_REGEX,
13
27
  isTaggedSentinel,
@@ -297,7 +311,7 @@ export class SchemaValidator {
297
311
  if (!isValid) {
298
312
  throw new RuntimeError(
299
313
  "ERR_RESOURCE_SCHEMA_VALIDATION_FAILED",
300
- `Invalid value passed: ${JSON.stringify(data)}. Error: ${formatAjvErrors(validate.errors)}`,
314
+ `Invalid value passed: ${describeValue(data)}. Error: ${formatAjvErrors(validate.errors)}`,
301
315
  );
302
316
  }
303
317
  },
@@ -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(