@telorun/kernel 0.42.0 → 0.44.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 (81) hide show
  1. package/dist/bundle/files-integrity.d.ts +25 -0
  2. package/dist/bundle/files-integrity.d.ts.map +1 -0
  3. package/dist/bundle/files-integrity.js +40 -0
  4. package/dist/bundle/files-integrity.js.map +1 -0
  5. package/dist/bundle/module-manifest.d.ts +22 -0
  6. package/dist/bundle/module-manifest.d.ts.map +1 -0
  7. package/dist/bundle/module-manifest.js +33 -0
  8. package/dist/bundle/module-manifest.js.map +1 -0
  9. package/dist/bundle/tar.d.ts +20 -0
  10. package/dist/bundle/tar.d.ts.map +1 -0
  11. package/dist/bundle/tar.js +63 -0
  12. package/dist/bundle/tar.js.map +1 -0
  13. package/dist/controllers/resource-definition/resource-definition-controller.d.ts +2 -0
  14. package/dist/controllers/resource-definition/resource-definition-controller.d.ts.map +1 -1
  15. package/dist/controllers/resource-definition/resource-definition-controller.js +46 -0
  16. package/dist/controllers/resource-definition/resource-definition-controller.js.map +1 -1
  17. package/dist/controllers/resource-definition/resource-inherited-controller.d.ts +21 -0
  18. package/dist/controllers/resource-definition/resource-inherited-controller.d.ts.map +1 -0
  19. package/dist/controllers/resource-definition/resource-inherited-controller.js +130 -0
  20. package/dist/controllers/resource-definition/resource-inherited-controller.js.map +1 -0
  21. package/dist/index.d.ts +9 -0
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +7 -0
  24. package/dist/index.js.map +1 -1
  25. package/dist/kernel.d.ts +11 -1
  26. package/dist/kernel.d.ts.map +1 -1
  27. package/dist/kernel.js +21 -2
  28. package/dist/kernel.js.map +1 -1
  29. package/dist/manifest-sources/local-manifest-cache-source.d.ts +1 -1
  30. package/dist/manifest-sources/local-manifest-cache-source.d.ts.map +1 -1
  31. package/dist/manifest-sources/local-manifest-cache-source.js +14 -93
  32. package/dist/manifest-sources/local-manifest-cache-source.js.map +1 -1
  33. package/dist/resource-context.d.ts +8 -0
  34. package/dist/resource-context.d.ts.map +1 -1
  35. package/dist/resource-context.js +10 -0
  36. package/dist/resource-context.js.map +1 -1
  37. package/dist/transports/oci/docker-credentials.d.ts +10 -0
  38. package/dist/transports/oci/docker-credentials.d.ts.map +1 -0
  39. package/dist/transports/oci/docker-credentials.js +75 -0
  40. package/dist/transports/oci/docker-credentials.js.map +1 -0
  41. package/dist/transports/oci/oci-client.d.ts +51 -0
  42. package/dist/transports/oci/oci-client.d.ts.map +1 -0
  43. package/dist/transports/oci/oci-client.js +170 -0
  44. package/dist/transports/oci/oci-client.js.map +1 -0
  45. package/dist/transports/oci/oci-ref.d.ts +21 -0
  46. package/dist/transports/oci/oci-ref.d.ts.map +1 -0
  47. package/dist/transports/oci/oci-ref.js +36 -0
  48. package/dist/transports/oci/oci-ref.js.map +1 -0
  49. package/dist/transports/oci/oci-transport.d.ts +29 -0
  50. package/dist/transports/oci/oci-transport.d.ts.map +1 -0
  51. package/dist/transports/oci/oci-transport.js +142 -0
  52. package/dist/transports/oci/oci-transport.js.map +1 -0
  53. package/dist/transports/registry-transport.d.ts +23 -0
  54. package/dist/transports/registry-transport.d.ts.map +1 -0
  55. package/dist/transports/registry-transport.js +225 -0
  56. package/dist/transports/registry-transport.js.map +1 -0
  57. package/dist/transports/transport-registry.d.ts +40 -0
  58. package/dist/transports/transport-registry.d.ts.map +1 -0
  59. package/dist/transports/transport-registry.js +70 -0
  60. package/dist/transports/transport-registry.js.map +1 -0
  61. package/dist/transports/transport.d.ts +92 -0
  62. package/dist/transports/transport.d.ts.map +1 -0
  63. package/dist/transports/transport.js +2 -0
  64. package/dist/transports/transport.js.map +1 -0
  65. package/package.json +7 -4
  66. package/src/bundle/files-integrity.ts +46 -0
  67. package/src/bundle/module-manifest.ts +49 -0
  68. package/src/bundle/tar.ts +78 -0
  69. package/src/controllers/resource-definition/resource-definition-controller.ts +65 -0
  70. package/src/controllers/resource-definition/resource-inherited-controller.ts +167 -0
  71. package/src/index.ts +22 -0
  72. package/src/kernel.ts +24 -2
  73. package/src/manifest-sources/local-manifest-cache-source.ts +14 -99
  74. package/src/resource-context.ts +14 -0
  75. package/src/transports/oci/docker-credentials.ts +85 -0
  76. package/src/transports/oci/oci-client.ts +237 -0
  77. package/src/transports/oci/oci-ref.ts +53 -0
  78. package/src/transports/oci/oci-transport.ts +192 -0
  79. package/src/transports/registry-transport.ts +281 -0
  80. package/src/transports/transport-registry.ts +91 -0
  81. package/src/transports/transport.ts +112 -0
@@ -0,0 +1,85 @@
1
+ import { spawn } from "node:child_process";
2
+ import { readFile } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import * as path from "node:path";
5
+
6
+ import { hostEnv } from "../../host-env.js";
7
+
8
+ export interface DockerCredential {
9
+ username: string;
10
+ password: string;
11
+ }
12
+
13
+ interface DockerConfig {
14
+ auths?: Record<string, { auth?: string; username?: string; password?: string }>;
15
+ credHelpers?: Record<string, string>;
16
+ credsStore?: string;
17
+ }
18
+
19
+ /** Path to `config.json`, honouring `DOCKER_CONFIG`. */
20
+ function dockerConfigPath(): string {
21
+ const override = hostEnv().DOCKER_CONFIG;
22
+ const dir = override && override.trim() ? override.trim() : path.join(homedir(), ".docker");
23
+ return path.join(dir, "config.json");
24
+ }
25
+
26
+ async function readDockerConfig(): Promise<DockerConfig | null> {
27
+ try {
28
+ return JSON.parse(await readFile(dockerConfigPath(), "utf-8")) as DockerConfig;
29
+ } catch {
30
+ return null; // no config, unreadable, or malformed → anonymous
31
+ }
32
+ }
33
+
34
+ /** Run `docker-credential-<helper> get` with `host` on stdin, per the Docker
35
+ * credential-helper protocol, returning `{Username, Secret}` or null. */
36
+ function runCredentialHelper(helper: string, host: string): Promise<DockerCredential | null> {
37
+ return new Promise((resolve) => {
38
+ const proc = spawn(`docker-credential-${helper}`, ["get"], {
39
+ stdio: ["pipe", "pipe", "ignore"],
40
+ });
41
+ let out = "";
42
+ proc.stdout.on("data", (c) => (out += c));
43
+ proc.on("error", () => resolve(null)); // helper binary not installed
44
+ proc.on("close", (code) => {
45
+ if (code !== 0) return resolve(null);
46
+ try {
47
+ const parsed = JSON.parse(out) as { Username?: string; Secret?: string };
48
+ if (parsed.Username && parsed.Secret) {
49
+ resolve({ username: parsed.Username, password: parsed.Secret });
50
+ } else {
51
+ resolve(null);
52
+ }
53
+ } catch {
54
+ resolve(null);
55
+ }
56
+ });
57
+ proc.stdin.end(host);
58
+ });
59
+ }
60
+
61
+ /** Resolve credentials for `host` from the ambient Docker credential chain:
62
+ * a per-registry credential helper, the global credential store, then the
63
+ * inline `auths` entry (base64 `user:password` or explicit fields). Returns
64
+ * null for anonymous access — the caller falls back to an anonymous token. */
65
+ export async function resolveDockerCredential(host: string): Promise<DockerCredential | null> {
66
+ const config = await readDockerConfig();
67
+ if (!config) return null;
68
+
69
+ const helper = config.credHelpers?.[host];
70
+ if (helper) return runCredentialHelper(helper, host);
71
+ if (config.credsStore) return runCredentialHelper(config.credsStore, host);
72
+
73
+ const entry = config.auths?.[host];
74
+ if (entry?.auth) {
75
+ const decoded = Buffer.from(entry.auth, "base64").toString("utf-8");
76
+ const colon = decoded.indexOf(":");
77
+ if (colon > 0) {
78
+ return { username: decoded.slice(0, colon), password: decoded.slice(colon + 1) };
79
+ }
80
+ }
81
+ if (entry?.username && entry?.password) {
82
+ return { username: entry.username, password: entry.password };
83
+ }
84
+ return null;
85
+ }
@@ -0,0 +1,237 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ import { resolveDockerCredential } from "./docker-credentials.js";
4
+
5
+ export const TELO_LAYER_MEDIA_TYPE = "application/vnd.telo.module.v1+tar";
6
+ export const OCI_MANIFEST_MEDIA_TYPE = "application/vnd.oci.image.manifest.v1+json";
7
+ export const OCI_EMPTY_CONFIG_MEDIA_TYPE = "application/vnd.oci.empty.v1+json";
8
+
9
+ /** The standard OCI empty descriptor — config `{}` (2 bytes). */
10
+ const EMPTY_CONFIG = Buffer.from("{}", "utf-8");
11
+ const EMPTY_CONFIG_DIGEST = "sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a";
12
+
13
+ const MANIFEST_ACCEPT = [
14
+ OCI_MANIFEST_MEDIA_TYPE,
15
+ "application/vnd.oci.image.index.v1+json",
16
+ "application/vnd.docker.distribution.manifest.v2+json",
17
+ "application/vnd.docker.distribution.manifest.list.v2+json",
18
+ ].join(", ");
19
+
20
+ export interface OciDescriptor {
21
+ mediaType: string;
22
+ digest: string;
23
+ size: number;
24
+ data?: string;
25
+ artifactType?: string;
26
+ }
27
+
28
+ export interface OciManifest {
29
+ schemaVersion: number;
30
+ mediaType?: string;
31
+ artifactType?: string;
32
+ config: OciDescriptor;
33
+ layers: OciDescriptor[];
34
+ }
35
+
36
+ function sha256Hex(bytes: Uint8Array): string {
37
+ return createHash("sha256").update(bytes).digest("hex");
38
+ }
39
+
40
+ /** Parse a `WWW-Authenticate: Bearer realm="...",service="...",scope="..."` header. */
41
+ function parseBearerChallenge(header: string): Record<string, string> {
42
+ const out: Record<string, string> = {};
43
+ const params = header.replace(/^Bearer\s+/i, "");
44
+ for (const m of params.matchAll(/([a-z]+)="([^"]*)"/gi)) {
45
+ out[m[1].toLowerCase()] = m[2];
46
+ }
47
+ return out;
48
+ }
49
+
50
+ /**
51
+ * A minimal OCI distribution (registry v2) client — exactly the surface Telo
52
+ * needs: pull manifest / pull blob / push blob / push manifest / list tags,
53
+ * with the `WWW-Authenticate` bearer-token handshake and the ambient Docker
54
+ * credential chain. Hand-rolled rather than depending on a stale/Deno-only
55
+ * client (see plan Decisions). One instance per `(host, repo)`; tokens are
56
+ * cached per scope for the instance's lifetime.
57
+ */
58
+ export class OciClient {
59
+ private readonly tokenByScope = new Map<string, string>();
60
+
61
+ constructor(
62
+ private readonly host: string,
63
+ private readonly repo: string,
64
+ ) {}
65
+
66
+ private base(): string {
67
+ return `https://${this.host}/v2/${this.repo}`;
68
+ }
69
+
70
+ private pullScope(): string {
71
+ return `repository:${this.repo}:pull`;
72
+ }
73
+
74
+ private pushScope(): string {
75
+ return `repository:${this.repo}:pull,push`;
76
+ }
77
+
78
+ /** Fetch with the bearer-token dance: try (cached token if any), and on 401
79
+ * resolve a token from the `WWW-Authenticate` challenge and retry once. */
80
+ private async authedFetch(
81
+ url: string,
82
+ init: RequestInit,
83
+ scope: string,
84
+ ): Promise<Response> {
85
+ const withToken = (token?: string): RequestInit => {
86
+ const headers = new Headers(init.headers);
87
+ if (token) headers.set("authorization", `Bearer ${token}`);
88
+ return { ...init, headers };
89
+ };
90
+
91
+ const cached = this.tokenByScope.get(scope);
92
+ let res = await fetch(url, withToken(cached));
93
+ if (res.status !== 401) return res;
94
+
95
+ const challenge = res.headers.get("www-authenticate");
96
+ if (!challenge) return res;
97
+ const token = await this.fetchToken(challenge, scope);
98
+ if (!token) return res;
99
+ this.tokenByScope.set(scope, token);
100
+ // Drain the 401 body so the connection can be reused.
101
+ await res.text().catch(() => {});
102
+ return fetch(url, withToken(token));
103
+ }
104
+
105
+ /** Exchange a bearer challenge for a token, authenticating to the token
106
+ * service with Docker credentials when available (else anonymous). */
107
+ private async fetchToken(challenge: string, scope: string): Promise<string | null> {
108
+ const params = parseBearerChallenge(challenge);
109
+ if (!params.realm) return null;
110
+ const tokenUrl = new URL(params.realm);
111
+ if (params.service) tokenUrl.searchParams.set("service", params.service);
112
+ tokenUrl.searchParams.set("scope", params.scope || scope);
113
+
114
+ const headers = new Headers();
115
+ const cred = await resolveDockerCredential(this.host);
116
+ if (cred) {
117
+ const basic = Buffer.from(`${cred.username}:${cred.password}`).toString("base64");
118
+ headers.set("authorization", `Basic ${basic}`);
119
+ }
120
+ const res = await fetch(tokenUrl.href, { headers });
121
+ if (!res.ok) return null;
122
+ const body = (await res.json()) as { token?: string; access_token?: string };
123
+ return body.token ?? body.access_token ?? null;
124
+ }
125
+
126
+ async pullManifest(reference: string): Promise<OciManifest> {
127
+ const res = await this.authedFetch(
128
+ `${this.base()}/manifests/${reference}`,
129
+ { headers: { accept: MANIFEST_ACCEPT } },
130
+ this.pullScope(),
131
+ );
132
+ if (!res.ok) {
133
+ throw new Error(
134
+ `OCI pull manifest ${this.repo}:${reference} on ${this.host} failed: ${res.status} ${res.statusText}`,
135
+ );
136
+ }
137
+ return (await res.json()) as OciManifest;
138
+ }
139
+
140
+ async pullBlob(digest: string): Promise<Buffer> {
141
+ const res = await this.authedFetch(`${this.base()}/blobs/${digest}`, {}, this.pullScope());
142
+ if (!res.ok) {
143
+ throw new Error(
144
+ `OCI pull blob ${digest} from ${this.repo} on ${this.host} failed: ${res.status} ${res.statusText}`,
145
+ );
146
+ }
147
+ return Buffer.from(await res.arrayBuffer());
148
+ }
149
+
150
+ async listTags(): Promise<string[]> {
151
+ const res = await this.authedFetch(`${this.base()}/tags/list`, {}, this.pullScope());
152
+ if (res.status === 404) return [];
153
+ if (!res.ok) {
154
+ throw new Error(
155
+ `OCI list tags for ${this.repo} on ${this.host} failed: ${res.status} ${res.statusText}`,
156
+ );
157
+ }
158
+ const body = (await res.json()) as { tags?: string[] | null };
159
+ return Array.isArray(body.tags) ? body.tags : [];
160
+ }
161
+
162
+ /** Upload `bytes` as a blob (skipped when already present), returning its
163
+ * `sha256:<hex>` digest. Two-step upload: obtain a session, then PUT with
164
+ * the digest. */
165
+ async pushBlob(bytes: Uint8Array): Promise<string> {
166
+ const digest = `sha256:${sha256Hex(bytes)}`;
167
+
168
+ const head = await this.authedFetch(
169
+ `${this.base()}/blobs/${digest}`,
170
+ { method: "HEAD" },
171
+ this.pushScope(),
172
+ );
173
+ if (head.ok) return digest; // already present
174
+ await head.text().catch(() => {});
175
+
176
+ const start = await this.authedFetch(
177
+ `${this.base()}/blobs/uploads/`,
178
+ { method: "POST" },
179
+ this.pushScope(),
180
+ );
181
+ if (start.status !== 202) {
182
+ throw new Error(
183
+ `OCI blob upload start for ${this.repo} on ${this.host} failed: ${start.status} ${start.statusText}`,
184
+ );
185
+ }
186
+ const location = start.headers.get("location");
187
+ await start.text().catch(() => {});
188
+ if (!location) {
189
+ throw new Error(`OCI blob upload for ${this.repo} returned no Location header`);
190
+ }
191
+ const uploadUrl = new URL(location, `https://${this.host}`);
192
+ uploadUrl.searchParams.set("digest", digest);
193
+
194
+ const put = await this.authedFetch(
195
+ uploadUrl.href,
196
+ {
197
+ method: "PUT",
198
+ headers: { "content-type": "application/octet-stream" },
199
+ body: bytes,
200
+ },
201
+ this.pushScope(),
202
+ );
203
+ if (put.status !== 201) {
204
+ const detail = await put.text().catch(() => "");
205
+ throw new Error(
206
+ `OCI blob PUT for ${this.repo} on ${this.host} failed: ${put.status} ${put.statusText} ${detail}`,
207
+ );
208
+ }
209
+ return digest;
210
+ }
211
+
212
+ async pushManifest(reference: string, manifest: OciManifest): Promise<void> {
213
+ const body = Buffer.from(JSON.stringify(manifest), "utf-8");
214
+ const res = await this.authedFetch(
215
+ `${this.base()}/manifests/${reference}`,
216
+ { method: "PUT", headers: { "content-type": OCI_MANIFEST_MEDIA_TYPE }, body },
217
+ this.pushScope(),
218
+ );
219
+ if (res.status !== 201) {
220
+ const detail = await res.text().catch(() => "");
221
+ throw new Error(
222
+ `OCI push manifest ${this.repo}:${reference} on ${this.host} failed: ${res.status} ${res.statusText} ${detail}`,
223
+ );
224
+ }
225
+ }
226
+
227
+ /** Push the standard empty config blob and return its descriptor. */
228
+ async pushEmptyConfig(): Promise<OciDescriptor> {
229
+ await this.pushBlob(EMPTY_CONFIG);
230
+ return {
231
+ mediaType: OCI_EMPTY_CONFIG_MEDIA_TYPE,
232
+ digest: EMPTY_CONFIG_DIGEST,
233
+ size: EMPTY_CONFIG.length,
234
+ data: EMPTY_CONFIG.toString("base64"),
235
+ };
236
+ }
237
+ }
@@ -0,0 +1,53 @@
1
+ import { splitIntegrity } from "@telorun/analyzer";
2
+
3
+ export const OCI_SCHEME = "oci://";
4
+
5
+ /** A parsed `oci://host/repo@reference` module ref.
6
+ *
7
+ * - `host` — the registry host (`ghcr.io`, `123.dkr.ecr.us-east-1.amazonaws.com`).
8
+ * - `repo` — the repository path (`aws/telo-s3`), possibly multi-segment.
9
+ * - `reference` — a tag (`1.2.0`) or a digest (`sha256:...`); the OCI address.
10
+ * - `integrity` — Telo's inline `sha256-<base64url>` hash when the ref is pinned
11
+ * (authoritative across transports; the OCI digest is only corroborating). */
12
+ export interface ParsedOciRef {
13
+ host: string;
14
+ repo: string;
15
+ reference: string;
16
+ integrity?: string;
17
+ }
18
+
19
+ /** True when `ref` uses the `oci://` scheme (integrity fragment tolerated). */
20
+ export function isOciRef(ref: string): boolean {
21
+ return splitIntegrity(ref).base.startsWith(OCI_SCHEME);
22
+ }
23
+
24
+ /** Parse `oci://host/repo@reference[#sha256-...]`. Throws on a malformed ref.
25
+ * A `reference` may be a tag or a `sha256:` digest; when absent (`@` omitted)
26
+ * it defaults to `latest`, matching OCI tooling. */
27
+ export function parseOciRef(ref: string): ParsedOciRef {
28
+ const { base, integrity } = splitIntegrity(ref);
29
+ if (!base.startsWith(OCI_SCHEME)) {
30
+ throw new Error(`Invalid OCI reference '${ref}', expected oci://host/repo@reference`);
31
+ }
32
+ const rest = base.slice(OCI_SCHEME.length);
33
+ const slash = rest.indexOf("/");
34
+ if (slash <= 0) {
35
+ throw new Error(`Invalid OCI reference '${ref}', missing repository path after host`);
36
+ }
37
+ const host = rest.slice(0, slash);
38
+ let repoAndRef = rest.slice(slash + 1);
39
+
40
+ let reference = "latest";
41
+ const at = repoAndRef.lastIndexOf("@");
42
+ // A digest reference is `repo@sha256:...` — the `@` before `sha256:` is the
43
+ // separator, not part of the repo. A tag reference has no `@`.
44
+ if (at > 0) {
45
+ reference = repoAndRef.slice(at + 1);
46
+ repoAndRef = repoAndRef.slice(0, at);
47
+ }
48
+ const repo = repoAndRef;
49
+ if (!host || !repo || !reference) {
50
+ throw new Error(`Invalid OCI reference '${ref}', expected oci://host/repo@reference`);
51
+ }
52
+ return { host, repo, reference, integrity };
53
+ }
@@ -0,0 +1,192 @@
1
+ import {
2
+ DEFAULT_MANIFEST_FILENAME,
3
+ IntegrityError,
4
+ verifyIntegrity,
5
+ type ManifestSource,
6
+ } from "@telorun/analyzer";
7
+
8
+ import { computeFilesIntegrity, injectFilesIntegrity } from "../../bundle/files-integrity.js";
9
+ import { readOwnerManifest } from "../../bundle/module-manifest.js";
10
+ import { makeTarGz, readTarGz, toPayloadFiles } from "../../bundle/tar.js";
11
+ import type {
12
+ FetchedArtifact,
13
+ PublishBundle,
14
+ PublishOptions,
15
+ PublishResult,
16
+ SiblingIdentity,
17
+ Transport,
18
+ } from "../transport.js";
19
+ import {
20
+ OciClient,
21
+ OCI_MANIFEST_MEDIA_TYPE,
22
+ TELO_LAYER_MEDIA_TYPE,
23
+ type OciManifest,
24
+ } from "./oci-client.js";
25
+ import { OCI_SCHEME, isOciRef, parseOciRef } from "./oci-ref.js";
26
+
27
+ /** Pull the module blob, returning its extracted entries and the `telo.yaml`
28
+ * bytes, verified against the ref's inline hash and its own `filesIntegrity`. */
29
+ async function pullVerified(ref: string): Promise<FetchedArtifact> {
30
+ const { host, repo, reference, integrity } = parseOciRef(ref);
31
+ const client = new OciClient(host, repo);
32
+ const manifest = await client.pullManifest(reference);
33
+ const layer =
34
+ manifest.layers.find((l) => l.mediaType === TELO_LAYER_MEDIA_TYPE) ?? manifest.layers[0];
35
+ if (!layer) {
36
+ throw new Error(`OCI artifact ${ref} has no layers`);
37
+ }
38
+ const tar = await client.pullBlob(layer.digest);
39
+ const entries = await readTarGz(tar);
40
+
41
+ const teloEntry = entries.find((e) => e.name === DEFAULT_MANIFEST_FILENAME);
42
+ if (!teloEntry) {
43
+ throw new Error(`OCI artifact ${ref} blob does not contain ${DEFAULT_MANIFEST_FILENAME}`);
44
+ }
45
+ const manifestText =
46
+ typeof teloEntry.content === "string" ? teloEntry.content : teloEntry.content.toString("utf-8");
47
+
48
+ // Telo's inline hash is authoritative; the OCI content digest only corroborates.
49
+ if (integrity) {
50
+ await verifyIntegrity(new TextEncoder().encode(manifestText), integrity, ref);
51
+ }
52
+
53
+ const files = toPayloadFiles(entries);
54
+
55
+ const { filesIntegrity } = readOwnerManifest(manifestText);
56
+ if (filesIntegrity) {
57
+ const actual = await computeFilesIntegrity(files);
58
+ if (actual !== filesIntegrity) {
59
+ throw new IntegrityError(
60
+ `Integrity check failed for ${ref}: filesIntegrity expected ${filesIntegrity}, ` +
61
+ `got ${actual}. The payload does not match the recorded hash.`,
62
+ );
63
+ }
64
+ }
65
+
66
+ return { manifest: manifestText, files };
67
+ }
68
+
69
+ /**
70
+ * OCI transport: `oci://host/repo@reference` modules on any OCI distribution
71
+ * registry (GHCR / ECR / Docker Hub / Harbor), over a hand-rolled minimal
72
+ * client. A module is a single artifact — one tar blob carrying `telo.yaml`
73
+ * and the `files:` payload — pushed under a standard OCI artifact manifest.
74
+ *
75
+ * Not browser-reachable (token handshake, Docker credentials, tar extraction),
76
+ * so its resolution `source` is Node-only; the editor resolves `oci://` imports
77
+ * through the discovery hub instead.
78
+ */
79
+ export class OciTransport implements Transport {
80
+ readonly source: ManifestSource;
81
+
82
+ constructor() {
83
+ this.source = {
84
+ supports: (url) => this.supports(url),
85
+ read: async (url) => {
86
+ const { manifest } = await pullVerified(url);
87
+ const { host, repo, reference } = parseOciRef(url);
88
+ return { text: manifest, source: `${OCI_SCHEME}${host}/${repo}@${reference}` };
89
+ },
90
+ resolveRelative: (base, relative) => this.resolveRelative(base, relative),
91
+ };
92
+ }
93
+
94
+ supports(ref: string): boolean {
95
+ return isOciRef(ref);
96
+ }
97
+
98
+ cacheLocation(ref: string): string[] | null {
99
+ let parsed: ReturnType<typeof parseOciRef>;
100
+ try {
101
+ parsed = parseOciRef(ref);
102
+ } catch {
103
+ return null;
104
+ }
105
+ return ["__oci", parsed.host, ...parsed.repo.split("/"), parsed.reference];
106
+ }
107
+
108
+ /** Resolve a relative import against an `oci://` base, normalizing the repo to
109
+ * a directory base so `../lib` under `oci://ghcr.io/aws/my-app` →
110
+ * `oci://ghcr.io/aws/lib` (never `oci://ghcr.io/lib`). Non-relative refs pass
111
+ * through. The reference/tag is dropped — the caller re-pins from the
112
+ * sibling's own version. */
113
+ resolveRelative(base: string, relative: string): string {
114
+ if (!relative.startsWith(".") && !relative.startsWith("/")) return relative;
115
+ const { host, repo } = parseOciRef(base);
116
+ // Resolve the repo path with standard URL semantics against a directory base.
117
+ const resolved = new URL(relative, `https://${host}/${repo}/`);
118
+ const newRepo = resolved.pathname.replace(/^\/+/, "");
119
+ return `${OCI_SCHEME}${host}/${newRepo}`;
120
+ }
121
+
122
+ async listVersions(ref: string): Promise<string[] | null> {
123
+ const { host, repo } = parseOciRef(ref);
124
+ const tags = await new OciClient(host, repo).listTags();
125
+ return tags;
126
+ }
127
+
128
+ async fetchArtifact(ref: string): Promise<FetchedArtifact> {
129
+ return pullVerified(ref);
130
+ }
131
+
132
+ async publish(
133
+ destination: string,
134
+ bundle: PublishBundle,
135
+ _opts: PublishOptions = {},
136
+ ): Promise<PublishResult> {
137
+ const identity = readOwnerManifest(bundle.manifest);
138
+ if (!identity.version) {
139
+ throw new Error("OCI publish requires metadata.version (used as the tag).");
140
+ }
141
+
142
+ // Destination is a full repo (`oci://host/repo`) or host-only
143
+ // (`oci://host`), which defaults the repo to `<namespace>/<name>`.
144
+ const afterScheme = destination.replace(/^oci:\/\//, "").replace(/\/+$/, "");
145
+ const slash = afterScheme.indexOf("/");
146
+ const host = slash > 0 ? afterScheme.slice(0, slash) : afterScheme;
147
+ let repo = slash > 0 ? afterScheme.slice(slash + 1) : "";
148
+ if (!repo) {
149
+ if (!identity.namespace || !identity.name) {
150
+ throw new Error(
151
+ `OCI publish to host-only '${destination}' needs metadata.namespace and metadata.name to default the repo.`,
152
+ );
153
+ }
154
+ repo = `${identity.namespace}/${identity.name}`;
155
+ }
156
+ const tag = identity.version;
157
+
158
+ // Pin the payload, then pack telo.yaml + files into the single module blob.
159
+ let manifestText = bundle.manifest;
160
+ if (bundle.files.length > 0) {
161
+ manifestText = injectFilesIntegrity(manifestText, await computeFilesIntegrity(bundle.files));
162
+ }
163
+ const tar = await makeTarGz([
164
+ { name: DEFAULT_MANIFEST_FILENAME, content: manifestText },
165
+ ...bundle.files.map((f) => ({ name: f.name, content: Buffer.from(f.content) })),
166
+ ]);
167
+
168
+ const client = new OciClient(host, repo);
169
+ const layerDigest = await client.pushBlob(tar);
170
+ const config = await client.pushEmptyConfig();
171
+ const manifest: OciManifest = {
172
+ schemaVersion: 2,
173
+ mediaType: OCI_MANIFEST_MEDIA_TYPE,
174
+ artifactType: TELO_LAYER_MEDIA_TYPE,
175
+ config,
176
+ layers: [{ mediaType: TELO_LAYER_MEDIA_TYPE, digest: layerDigest, size: tar.length }],
177
+ };
178
+ await client.pushManifest(tag, manifest);
179
+
180
+ return { label: `${repo}@${tag}`, url: `${OCI_SCHEME}${host}/${repo}@${tag}` };
181
+ }
182
+
183
+ canonicalizeSiblingRef(
184
+ destination: string,
185
+ relativeSource: string,
186
+ sibling: SiblingIdentity,
187
+ ): string {
188
+ // The sibling's repo is the destination repo with the relative applied; the
189
+ // version is its own (identity is the ref, not metadata).
190
+ return `${this.resolveRelative(destination, relativeSource)}@${sibling.version}`;
191
+ }
192
+ }