@nylorun/runtime 0.15.1-beta → 0.16.0-beta
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +82 -0
- package/README.md +33 -6
- package/dist/adapters/execution/restate.d.ts +9 -1
- package/dist/adapters/execution/restate.js +64 -5
- package/dist/adapters/replication/pgoutput.js +3 -1
- package/dist/adapters/sandbox/local.d.ts +10 -0
- package/dist/adapters/sandbox/local.js +195 -0
- package/dist/adapters/sandbox/virtual.js +36 -1
- package/dist/admin-openapi.json +37 -3
- package/dist/api/a2a/tasks.js +6 -1
- package/dist/api/ag-ui/history.js +6 -1
- package/dist/api/ag-ui/routes.js +5 -1
- package/dist/api/components.d.ts +2673 -655
- package/dist/api/components.js +17 -1
- package/dist/api/gate/keys.d.ts +4 -3
- package/dist/api/gate/routes.d.ts +19 -3
- package/dist/api/gate/routes.js +162 -53
- package/dist/api/http/app.js +4 -0
- package/dist/api/http/define.d.ts +10 -0
- package/dist/api/http/define.js +2 -1
- package/dist/api/http/routes/artifacts.d.ts +11 -0
- package/dist/api/http/routes/artifacts.js +518 -0
- package/dist/api/http/routes/sandboxes.d.ts +9 -0
- package/dist/api/http/routes/sandboxes.js +152 -0
- package/dist/api/http/routes/sessions.js +3 -1
- package/dist/api/http/routes/tenant.js +36 -2
- package/dist/artifacts/config.d.ts +23 -0
- package/dist/artifacts/config.js +27 -0
- package/dist/artifacts/export.d.ts +8 -0
- package/dist/artifacts/export.js +205 -0
- package/dist/artifacts/files.d.ts +36 -0
- package/dist/artifacts/files.js +45 -0
- package/dist/artifacts/folders.d.ts +60 -0
- package/dist/artifacts/folders.js +209 -0
- package/dist/artifacts/links.d.ts +28 -0
- package/dist/artifacts/links.js +92 -0
- package/dist/artifacts/media-types.d.ts +13 -0
- package/dist/artifacts/media-types.js +80 -0
- package/dist/artifacts/parts.d.ts +34 -0
- package/dist/artifacts/parts.js +38 -0
- package/dist/artifacts/service.d.ts +74 -0
- package/dist/artifacts/service.js +301 -0
- package/dist/artifacts/workspace.d.ts +60 -0
- package/dist/artifacts/workspace.js +57 -0
- package/dist/artifacts/zip.d.ts +10 -0
- package/dist/artifacts/zip.js +126 -0
- package/dist/blob/fs.d.ts +13 -0
- package/dist/blob/fs.js +194 -0
- package/dist/blob/index.d.ts +9 -0
- package/dist/blob/index.js +9 -0
- package/dist/blob/s3.d.ts +42 -0
- package/dist/blob/s3.js +366 -0
- package/dist/blob/types.d.ts +108 -0
- package/dist/blob/types.js +85 -0
- package/dist/core/sandbox-routes.d.ts +2 -2
- package/dist/core/sandbox-routes.js +4 -3
- package/dist/execution/memory.d.ts +11 -1
- package/dist/execution/memory.js +77 -3
- package/dist/execution/types.d.ts +54 -1
- package/dist/execution/types.js +31 -0
- package/dist/gates/contract.d.ts +14 -13
- package/dist/gates/contract.js +13 -12
- package/dist/gates/egress.d.ts +72 -0
- package/dist/gates/egress.js +319 -0
- package/dist/gates/handler.d.ts +3 -0
- package/dist/gates/handler.js +3 -2
- package/dist/gates/http-client.d.ts +3 -2
- package/dist/gates/http-client.js +17 -6
- package/dist/gates/in-process.d.ts +13 -1
- package/dist/gates/in-process.js +11 -0
- package/dist/gates/inflight.d.ts +23 -4
- package/dist/gates/inflight.js +18 -4
- package/dist/gates/model-gate.d.ts +6 -2
- package/dist/gates/tool-calls.d.ts +9 -5
- package/dist/gates/tool-calls.js +6 -6
- package/dist/gates/tool-client.d.ts +9 -2
- package/dist/gates/tool-client.js +56 -22
- package/dist/gates/tool-contract.d.ts +12 -7
- package/dist/gates/tool-contract.js +10 -4
- package/dist/gates/tool-gate.d.ts +1 -0
- package/dist/harness/calls.d.ts +52 -0
- package/dist/harness/calls.js +113 -0
- package/dist/harness/executors.d.ts +22 -0
- package/dist/harness/executors.js +96 -0
- package/dist/harness/main.d.ts +6 -0
- package/dist/harness/main.js +103 -0
- package/dist/harness/pod.d.ts +21 -0
- package/dist/harness/pod.js +119 -0
- package/dist/harness/run-tokens.d.ts +18 -0
- package/dist/harness/run-tokens.js +15 -0
- package/dist/harness/service.d.ts +46 -0
- package/dist/harness/service.js +179 -0
- package/dist/harness/ws-client.d.ts +43 -0
- package/dist/harness/ws-client.js +122 -0
- package/dist/harness/ws-port.d.ts +22 -0
- package/dist/harness/ws-port.js +78 -0
- package/dist/harness-api/in-process.d.ts +24 -0
- package/dist/harness-api/in-process.js +119 -0
- package/dist/harness-api/record.d.ts +44 -0
- package/dist/harness-api/record.js +250 -0
- package/dist/harness-api/renew.d.ts +37 -0
- package/dist/harness-api/renew.js +77 -0
- package/dist/harness-api/server.d.ts +104 -0
- package/dist/harness-api/server.js +408 -0
- package/dist/harness-api/start.d.ts +36 -0
- package/dist/harness-api/start.js +118 -0
- package/dist/harness-api/workspace.d.ts +53 -0
- package/dist/harness-api/workspace.js +201 -0
- package/dist/harness-api/ws-server.d.ts +34 -0
- package/dist/harness-api/ws-server.js +227 -0
- package/dist/host/app.js +23 -2
- package/dist/host/gates.d.ts +27 -2
- package/dist/host/gates.js +46 -1
- package/dist/host/http.d.ts +4 -1
- package/dist/host/http.js +6 -1
- package/dist/host/main.js +135 -27
- package/dist/host/stack-config.d.ts +101 -1
- package/dist/host/stack-config.js +199 -8
- package/dist/mcp/snapshot.d.ts +8 -1
- package/dist/mcp/snapshot.js +28 -0
- package/dist/media.d.ts +5 -24
- package/dist/media.js +5 -0
- package/dist/model/pi-model.d.ts +6 -2
- package/dist/model/pi-model.js +39 -22
- package/dist/node/index.d.ts +1 -1
- package/dist/node/index.js +1 -1
- package/dist/openapi.json +15245 -8850
- package/dist/record/index.d.ts +1 -0
- package/dist/record/index.js +1 -0
- package/dist/record/sandbox.d.ts +25 -0
- package/dist/record/sandbox.js +43 -0
- package/dist/sandbox/capability.d.ts +3 -0
- package/dist/sandbox/capability.js +10 -0
- package/dist/sandbox/egress-token.d.ts +26 -0
- package/dist/sandbox/egress-token.js +8 -0
- package/dist/sandbox/join.d.ts +37 -0
- package/dist/sandbox/join.js +165 -0
- package/dist/sandbox/manager.d.ts +99 -34
- package/dist/sandbox/manager.js +216 -84
- package/dist/sandbox/placement.d.ts +27 -0
- package/dist/sandbox/placement.js +44 -0
- package/dist/sandbox/pods/client.d.ts +90 -0
- package/dist/sandbox/pods/client.js +79 -0
- package/dist/sandbox/pods/lifecycle.d.ts +91 -0
- package/dist/sandbox/pods/lifecycle.js +178 -0
- package/dist/sandbox/pods/name.d.ts +1 -0
- package/dist/sandbox/pods/name.js +28 -0
- package/dist/sandbox/pods/network-gate.d.ts +25 -0
- package/dist/sandbox/pods/network-gate.js +71 -0
- package/dist/sandbox/pods/reconcile.d.ts +11 -0
- package/dist/sandbox/pods/reconcile.js +152 -0
- package/dist/sandbox/pods/spec.d.ts +45 -0
- package/dist/sandbox/pods/spec.js +62 -0
- package/dist/sandbox/records.d.ts +29 -0
- package/dist/sandbox/records.js +79 -0
- package/dist/sandbox/resolve.d.ts +5 -0
- package/dist/sandbox/resolve.js +3 -2
- package/dist/sandbox/session-sandbox.js +7 -5
- package/dist/sandbox/share.d.ts +11 -0
- package/dist/sandbox/share.js +10 -0
- package/dist/sandbox/tenant-config.d.ts +16 -1
- package/dist/sandbox/tenant-config.js +9 -0
- package/dist/sandbox/types.d.ts +29 -1
- package/dist/sandbox/types.js +13 -1
- package/dist/store/postgres/drizzle/0004_sandbox_resources.sql +20 -0
- package/dist/store/postgres/drizzle/0005_artifacts.sql +27 -0
- package/dist/store/postgres/drizzle/0006_folder_artifacts.sql +12 -0
- package/dist/store/postgres/drizzle/0007_sandbox_pods.sql +14 -0
- package/dist/store/postgres/drizzle/meta/_journal.json +28 -0
- package/dist/store/postgres/record-writer.d.ts +6 -1
- package/dist/store/postgres/record-writer.js +20 -2
- package/dist/store/postgres/schema.d.ts +894 -0
- package/dist/store/postgres/schema.js +130 -2
- package/dist/store/postgres/store.js +289 -4
- package/dist/store/types.d.ts +143 -6
- package/dist/streams/control.d.ts +2 -0
- package/dist/streams/control.js +5 -0
- package/dist/streams/types.d.ts +10 -1
- package/dist/tenant/advance.js +218 -160
- package/dist/tenant/artifact-tool.d.ts +5 -0
- package/dist/tenant/artifact-tool.js +119 -0
- package/dist/tenant/commands.js +30 -5
- package/dist/tenant/context.d.ts +64 -5
- package/dist/tenant/effects.d.ts +19 -13
- package/dist/tenant/effects.js +5 -382
- package/dist/tenant/history.d.ts +13 -23
- package/dist/tenant/history.js +14 -62
- package/dist/tenant/host-token.d.ts +92 -0
- package/dist/tenant/host-token.js +179 -0
- package/dist/tenant/module.js +1 -0
- package/dist/tenant/paths.d.ts +2 -1
- package/dist/tenant/paths.js +3 -1
- package/dist/tenant/reset.d.ts +5 -2
- package/dist/tenant/reset.js +22 -2
- package/dist/tenant/run-grants.d.ts +49 -0
- package/dist/tenant/run-grants.js +62 -0
- package/dist/tenant/run-token.d.ts +70 -0
- package/dist/tenant/run-token.js +138 -0
- package/dist/tenant/runtime.d.ts +38 -0
- package/dist/tenant/runtime.js +120 -32
- package/dist/tenant/sandboxes.d.ts +119 -0
- package/dist/tenant/sandboxes.js +551 -0
- package/dist/tenant/session.d.ts +2 -13
- package/dist/tenant/session.js +4 -28
- package/dist/tenant/sessions.d.ts +5 -0
- package/dist/tenant/sessions.js +28 -1
- package/dist/tenant/status.d.ts +8 -3
- package/dist/tenant/status.js +5 -0
- package/dist/tenant/streams.d.ts +7 -0
- package/dist/tenant/streams.js +24 -1
- package/dist/tenant/sweep.js +37 -4
- package/dist/tenant/tokens.js +9 -2
- package/dist/tenant/types.d.ts +18 -1
- package/dist/tenant/worker.d.ts +11 -5
- package/dist/tenant/worker.js +12 -6
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +13 -10
- package/dist/adapters/media.d.ts +0 -24
- package/dist/adapters/media.js +0 -111
|
@@ -0,0 +1,518 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Artifacts (`/v1/artifacts/**`, protocol 6): upload a file in one streamed request (a new
|
|
3
|
+
* artifact, or a new version of one), list and read them, download a version with HTTP Range
|
|
4
|
+
* through the Runtime, mint a capability link, and delete. `GET /v1/artifact-links/{token}`
|
|
5
|
+
* opens a capability link with no other credential and no `Nylorun-Protocol`. The Tenant's
|
|
6
|
+
* limits are `GET`/`PUT /v1/tenant/artifacts`.
|
|
7
|
+
*
|
|
8
|
+
* Folders (F8.2) come from turn-end exports: a version's tree, one file by path (with Range), a
|
|
9
|
+
* diff between two versions and a streamed zip. A folder's capability link opens its zip, or
|
|
10
|
+
* one file when minted with `file`.
|
|
11
|
+
*
|
|
12
|
+
* Acting for a person, a caller reaches only the artifacts of that person's sessions, as it
|
|
13
|
+
* reaches only their sessions.
|
|
14
|
+
*/
|
|
15
|
+
import { posix } from "node:path";
|
|
16
|
+
import { z } from "zod";
|
|
17
|
+
import { ArtifactLabelsSchema, CreateArtifactLinkRequestSchema, PutTenantArtifactsRequestSchema, } from "@nylorun/core/contracts";
|
|
18
|
+
import { ArtifactDiff, ArtifactTree, ArtifactLink, ArtifactView, CreateArtifactLinkRequest, DeleteArtifactResponse, ListArtifactsResponse, PutTenantArtifactsRequest, TenantArtifactsView, UploadArtifactResponse, } from "../../components.js";
|
|
19
|
+
import { BlobRangeError } from "../../../blob/index.js";
|
|
20
|
+
import { effectiveArtifactLimits, readArtifactsConfig, writeArtifactsConfig, } from "../../../artifacts/config.js";
|
|
21
|
+
import { contentKey, diffManifests, entryAt, readManifest } from "../../../artifacts/folders.js";
|
|
22
|
+
import { mintArtifactLink, verifyArtifactLink } from "../../../artifacts/links.js";
|
|
23
|
+
import { zipStream, zipTooLarge } from "../../../artifacts/zip.js";
|
|
24
|
+
import { normalizeContentType } from "../../../artifacts/media-types.js";
|
|
25
|
+
import { artifactContent, deleteArtifact, folderContent, getArtifact, listArtifacts, readableArtifact, uploadArtifact, versionOf, } from "../../../artifacts/service.js";
|
|
26
|
+
import { accessOf } from "../../../tenant/auth.js";
|
|
27
|
+
import { fail } from "../../../tenant/http.js";
|
|
28
|
+
import { readJson } from "../body.js";
|
|
29
|
+
import { tenantRoute } from "../define.js";
|
|
30
|
+
import { jsonResponse } from "../respond.js";
|
|
31
|
+
/** Reading and writing artifacts: whoever may use sessions, a person only in their own. */
|
|
32
|
+
const OWN = {
|
|
33
|
+
credentials: ["application", "subject", "token"],
|
|
34
|
+
scopes: ["sessions:own"],
|
|
35
|
+
browser: true,
|
|
36
|
+
};
|
|
37
|
+
/** An upload: the same callers, with the file's bytes as the body. */
|
|
38
|
+
const UPLOAD = { ...OWN, bytes: true };
|
|
39
|
+
/** A capability link: no credential, no protocol header; the token in the path is the grant. */
|
|
40
|
+
const LINK = {
|
|
41
|
+
credentials: ["application", "subject", "token", "publishable"],
|
|
42
|
+
scopes: "any",
|
|
43
|
+
browser: true,
|
|
44
|
+
anonymous: true,
|
|
45
|
+
unversioned: true,
|
|
46
|
+
};
|
|
47
|
+
const SETTINGS = {
|
|
48
|
+
credentials: ["application", "subject"],
|
|
49
|
+
scopes: ["tenant:settings"],
|
|
50
|
+
};
|
|
51
|
+
const json = (schema, description) => ({
|
|
52
|
+
description,
|
|
53
|
+
content: { "application/json": { schema } },
|
|
54
|
+
});
|
|
55
|
+
const fileBody = {
|
|
56
|
+
required: true,
|
|
57
|
+
description: "The file's bytes; `Content-Type` is its media type",
|
|
58
|
+
content: { "*/*": { schema: z.string().meta({ format: "binary" }) } },
|
|
59
|
+
};
|
|
60
|
+
const fileContent = (description) => ({
|
|
61
|
+
description,
|
|
62
|
+
headers: z.object({
|
|
63
|
+
"Accept-Ranges": z.string().meta({ description: "`bytes`" }),
|
|
64
|
+
"Content-Range": z
|
|
65
|
+
.string()
|
|
66
|
+
.optional()
|
|
67
|
+
.meta({ description: "The bytes sent, on a `206`: `bytes <start>-<end>/<size>`" }),
|
|
68
|
+
ETag: z.string().meta({ description: "The version's SHA-256, quoted" }),
|
|
69
|
+
}),
|
|
70
|
+
content: { "*/*": { schema: z.string().meta({ format: "binary" }) } },
|
|
71
|
+
});
|
|
72
|
+
const ranged = {
|
|
73
|
+
206: fileContent("The bytes the `Range` header asked for"),
|
|
74
|
+
416: { description: "The `Range` starts past the end of the file" },
|
|
75
|
+
};
|
|
76
|
+
const artifactId = z.object({ artifactId: z.string() });
|
|
77
|
+
const versionParam = z.object({
|
|
78
|
+
artifactId: z.string(),
|
|
79
|
+
version: z.string().meta({ description: "A version number, or `latest`" }),
|
|
80
|
+
});
|
|
81
|
+
const labelQuery = z
|
|
82
|
+
.union([z.string(), z.array(z.string())])
|
|
83
|
+
.optional()
|
|
84
|
+
.meta({ description: "`key=value`; repeat it for several labels" });
|
|
85
|
+
/** `label=k=v`, repeated. */
|
|
86
|
+
function labelsOf(values) {
|
|
87
|
+
if (values === undefined || values.length === 0)
|
|
88
|
+
return undefined;
|
|
89
|
+
const labels = {};
|
|
90
|
+
for (const value of values) {
|
|
91
|
+
const at = value.indexOf("=");
|
|
92
|
+
if (at <= 0)
|
|
93
|
+
fail(400, `label must be key=value, not ${value}`);
|
|
94
|
+
labels[value.slice(0, at)] = value.slice(at + 1);
|
|
95
|
+
}
|
|
96
|
+
const parsed = ArtifactLabelsSchema.safeParse(labels);
|
|
97
|
+
if (!parsed.success)
|
|
98
|
+
fail(400, parsed.error.issues[0]?.message ?? "Invalid labels");
|
|
99
|
+
return labels;
|
|
100
|
+
}
|
|
101
|
+
function versionNumber(value) {
|
|
102
|
+
if (value === "latest")
|
|
103
|
+
return undefined;
|
|
104
|
+
if (value === undefined || !/^[1-9]\d{0,8}$/.test(value))
|
|
105
|
+
return fail(400, "version must be a version number or latest");
|
|
106
|
+
return Number(value);
|
|
107
|
+
}
|
|
108
|
+
/** The declared `Content-Length`, when there is one. */
|
|
109
|
+
function declaredLength(request) {
|
|
110
|
+
const raw = request.headers.get("content-length");
|
|
111
|
+
return raw !== null && /^\d+$/.test(raw) ? Number(raw) : undefined;
|
|
112
|
+
}
|
|
113
|
+
/** The declared media type: absent, or `application/octet-stream`, means "from the name". */
|
|
114
|
+
function declaredType(request) {
|
|
115
|
+
const raw = request.headers.get("content-type") ?? undefined;
|
|
116
|
+
if (raw === undefined)
|
|
117
|
+
return undefined;
|
|
118
|
+
const type = normalizeContentType(raw);
|
|
119
|
+
if (type === undefined)
|
|
120
|
+
return fail(400, "Content-Type is not a media type");
|
|
121
|
+
return type.toLowerCase().startsWith("application/octet-stream") ? undefined : type;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* One `bytes=` range of a file of `size` bytes: what to send, `"unsatisfiable"`, or undefined
|
|
125
|
+
* to send the whole file (no header, several ranges, or one the Runtime ignores).
|
|
126
|
+
*/
|
|
127
|
+
export function parseRange(header, size) {
|
|
128
|
+
if (header === undefined)
|
|
129
|
+
return undefined;
|
|
130
|
+
const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
|
|
131
|
+
if (!match || (match[1] === "" && match[2] === ""))
|
|
132
|
+
return undefined;
|
|
133
|
+
if (match[1] === "") {
|
|
134
|
+
const suffix = Number(match[2]);
|
|
135
|
+
if (suffix === 0 || size === 0)
|
|
136
|
+
return "unsatisfiable";
|
|
137
|
+
return { start: Math.max(0, size - suffix), end: size - 1 };
|
|
138
|
+
}
|
|
139
|
+
const start = Number(match[1]);
|
|
140
|
+
if (match[2] !== "" && Number(match[2]) < start)
|
|
141
|
+
return undefined;
|
|
142
|
+
if (start >= size)
|
|
143
|
+
return "unsatisfiable";
|
|
144
|
+
const end = match[2] === "" ? size - 1 : Math.min(Number(match[2]), size - 1);
|
|
145
|
+
return { start, end };
|
|
146
|
+
}
|
|
147
|
+
/** `inline; filename=…` (or `attachment`), with an ASCII fallback and the UTF-8 name. */
|
|
148
|
+
function disposition(name, type = "inline") {
|
|
149
|
+
const ascii = name.replace(/[^\x20-\x7e]|["\\]/g, "_");
|
|
150
|
+
return `${type}; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(name)}`;
|
|
151
|
+
}
|
|
152
|
+
/** A file artifact's version, to serve; a folder is a 400 (its files, tree and zip serve it). */
|
|
153
|
+
function fileOf(artifact, version) {
|
|
154
|
+
if (artifact.kind !== "file")
|
|
155
|
+
fail(400, `Artifact ${artifact.id} is a folder; read its tree, its files or its zip`);
|
|
156
|
+
return { ...version, name: artifact.name };
|
|
157
|
+
}
|
|
158
|
+
/** A folder's file at `path`, to serve. */
|
|
159
|
+
function folderFile(manifest, path) {
|
|
160
|
+
const entry = entryAt(manifest, path);
|
|
161
|
+
return {
|
|
162
|
+
name: posix.basename(entry.path),
|
|
163
|
+
blobKey: contentKey(entry.sha256),
|
|
164
|
+
size: entry.size,
|
|
165
|
+
sha256: entry.sha256,
|
|
166
|
+
contentType: entry.contentType,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* The file's bytes, streamed from the Object store: the whole file, or the one range asked
|
|
171
|
+
* for (`206`, `Content-Range`), or `416` when it starts past the end. Served so a browser never
|
|
172
|
+
* runs it as a page of the Runtime's origin.
|
|
173
|
+
*/
|
|
174
|
+
async function contentResponse(ctx, version, request) {
|
|
175
|
+
const range = parseRange(request.headers.get("range") ?? undefined, version.size);
|
|
176
|
+
const headers = {
|
|
177
|
+
"accept-ranges": "bytes",
|
|
178
|
+
etag: `"${version.sha256}"`,
|
|
179
|
+
"cache-control": "private, max-age=0",
|
|
180
|
+
"content-disposition": disposition(version.name),
|
|
181
|
+
"x-content-type-options": "nosniff",
|
|
182
|
+
"content-security-policy": "default-src 'none'; sandbox",
|
|
183
|
+
};
|
|
184
|
+
if (range === "unsatisfiable")
|
|
185
|
+
return new Response(null, {
|
|
186
|
+
status: 416,
|
|
187
|
+
headers: { ...headers, "content-range": `bytes */${version.size}` },
|
|
188
|
+
});
|
|
189
|
+
let got;
|
|
190
|
+
try {
|
|
191
|
+
got = await ctx.blobs.get(version.blobKey, {
|
|
192
|
+
...(range ? { range } : {}),
|
|
193
|
+
signal: request.signal,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
catch (error) {
|
|
197
|
+
if (error instanceof BlobRangeError)
|
|
198
|
+
return new Response(null, {
|
|
199
|
+
status: 416,
|
|
200
|
+
headers: { ...headers, "content-range": `bytes */${version.size}` },
|
|
201
|
+
});
|
|
202
|
+
throw error;
|
|
203
|
+
}
|
|
204
|
+
if (!got)
|
|
205
|
+
return fail(404, "Artifact bytes not found");
|
|
206
|
+
const sent = got.range ?? (range ? range : undefined);
|
|
207
|
+
const length = sent ? sent.end - sent.start + 1 : got.size;
|
|
208
|
+
return new Response(got.body, {
|
|
209
|
+
status: sent ? 206 : 200,
|
|
210
|
+
headers: {
|
|
211
|
+
...headers,
|
|
212
|
+
"content-type": version.contentType,
|
|
213
|
+
"content-length": String(length),
|
|
214
|
+
...(sent ? { "content-range": `bytes ${sent.start}-${sent.end}/${got.size}` } : {}),
|
|
215
|
+
},
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
/** A folder version as a zip, streamed: each file read from the Object store as it is reached. */
|
|
219
|
+
function zipResponse(ctx, artifact, version, manifest, request) {
|
|
220
|
+
const refused = zipTooLarge(manifest.entries.length, version.size);
|
|
221
|
+
if (refused)
|
|
222
|
+
fail(400, refused);
|
|
223
|
+
const iterator = zipStream(manifest.entries.map((entry) => ({
|
|
224
|
+
name: entry.path,
|
|
225
|
+
open: async () => {
|
|
226
|
+
const got = await ctx.blobs.get(contentKey(entry.sha256), { signal: request.signal });
|
|
227
|
+
if (!got)
|
|
228
|
+
throw new Error(`The bytes of ${entry.path} are missing from the Object store`);
|
|
229
|
+
return got.body;
|
|
230
|
+
},
|
|
231
|
+
})), new Date(version.createdAt));
|
|
232
|
+
const body = new ReadableStream({
|
|
233
|
+
async pull(controller) {
|
|
234
|
+
const next = await iterator.next();
|
|
235
|
+
if (next.done)
|
|
236
|
+
controller.close();
|
|
237
|
+
else
|
|
238
|
+
controller.enqueue(next.value);
|
|
239
|
+
},
|
|
240
|
+
async cancel() {
|
|
241
|
+
await iterator.return(undefined);
|
|
242
|
+
},
|
|
243
|
+
});
|
|
244
|
+
return new Response(body, {
|
|
245
|
+
status: 200,
|
|
246
|
+
headers: {
|
|
247
|
+
"content-type": "application/zip",
|
|
248
|
+
"content-disposition": disposition(`${artifact.name}-v${version.version}.zip`, "attachment"),
|
|
249
|
+
"cache-control": "private, max-age=0",
|
|
250
|
+
etag: `"${version.sha256}"`,
|
|
251
|
+
"x-content-type-options": "nosniff",
|
|
252
|
+
},
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
export function artifactRoutes(api) {
|
|
256
|
+
tenantRoute(api, UPLOAD, {
|
|
257
|
+
method: "post",
|
|
258
|
+
path: "/v1/artifacts",
|
|
259
|
+
tags: ["Artifacts"],
|
|
260
|
+
summary: "Upload a file as a new artifact",
|
|
261
|
+
description: "Streams the body to the Object store in one request, up to the Tenant's per-file limit and within its total; a body past either stores nothing (`413 limit_exceeded`). Its `Content-Type` is the file's media type (default: from the name). Acting for a person, the artifact belongs to one of their sessions. A session's artifact appears in its history as `artifact.created`.",
|
|
262
|
+
request: {
|
|
263
|
+
query: z.object({
|
|
264
|
+
name: z.string().meta({ description: "The file's name, e.g. `brief.pdf`" }),
|
|
265
|
+
sessionId: z.string().optional().meta({ description: "The session it belongs to" }),
|
|
266
|
+
label: labelQuery,
|
|
267
|
+
}),
|
|
268
|
+
body: fileBody,
|
|
269
|
+
},
|
|
270
|
+
responses: {
|
|
271
|
+
201: json(UploadArtifactResponse, "The artifact and its first version"),
|
|
272
|
+
413: { description: "Larger than the per-file limit, or past the Tenant's total (`limit_exceeded`)" },
|
|
273
|
+
},
|
|
274
|
+
}, async (c) => {
|
|
275
|
+
const scope = c.get("scope");
|
|
276
|
+
const request = c.req.raw;
|
|
277
|
+
const name = c.req.query("name");
|
|
278
|
+
const sessionId = c.req.query("sessionId");
|
|
279
|
+
const labels = labelsOf(c.req.queries("label"));
|
|
280
|
+
const contentType = declaredType(request);
|
|
281
|
+
const uploaded = await uploadArtifact(c.env.tenant, {
|
|
282
|
+
...(name === undefined ? {} : { name }),
|
|
283
|
+
...(contentType === undefined ? {} : { contentType }),
|
|
284
|
+
...(sessionId === undefined ? {} : { sessionId }),
|
|
285
|
+
...(labels === undefined ? {} : { labels }),
|
|
286
|
+
...(declaredLength(request) === undefined ? {} : { declaredBytes: declaredLength(request) }),
|
|
287
|
+
source: "upload",
|
|
288
|
+
}, request.body ?? new Uint8Array(), accessOf(scope), request.signal);
|
|
289
|
+
return jsonResponse(201, uploaded);
|
|
290
|
+
});
|
|
291
|
+
tenantRoute(api, UPLOAD, {
|
|
292
|
+
method: "post",
|
|
293
|
+
path: "/v1/artifacts/{artifactId}/versions",
|
|
294
|
+
tags: ["Artifacts"],
|
|
295
|
+
summary: "Upload a new version of an artifact",
|
|
296
|
+
description: "Streams the body as the artifact's next version, with the same limits as a new artifact. A session's artifact gets `artifact.version.created` in its history.",
|
|
297
|
+
request: { params: artifactId, body: fileBody },
|
|
298
|
+
responses: {
|
|
299
|
+
201: json(UploadArtifactResponse, "The artifact and the version the upload created"),
|
|
300
|
+
413: { description: "Larger than the per-file limit, or past the Tenant's total (`limit_exceeded`)" },
|
|
301
|
+
},
|
|
302
|
+
}, async (c) => {
|
|
303
|
+
const request = c.req.raw;
|
|
304
|
+
const contentType = declaredType(request);
|
|
305
|
+
const uploaded = await uploadArtifact(c.env.tenant, {
|
|
306
|
+
artifactId: c.req.param("artifactId"),
|
|
307
|
+
...(contentType === undefined ? {} : { contentType }),
|
|
308
|
+
...(declaredLength(request) === undefined ? {} : { declaredBytes: declaredLength(request) }),
|
|
309
|
+
source: "upload",
|
|
310
|
+
}, request.body ?? new Uint8Array(), accessOf(c.get("scope")), request.signal);
|
|
311
|
+
return jsonResponse(201, uploaded);
|
|
312
|
+
});
|
|
313
|
+
tenantRoute(api, OWN, {
|
|
314
|
+
method: "get",
|
|
315
|
+
path: "/v1/artifacts",
|
|
316
|
+
tags: ["Artifacts"],
|
|
317
|
+
summary: "List artifacts",
|
|
318
|
+
description: "Oldest first: of one session, or of every session the caller reaches (an application key: every artifact).",
|
|
319
|
+
request: {
|
|
320
|
+
query: z.object({ sessionId: z.string().optional().meta({ description: "Only this session's" }) }),
|
|
321
|
+
},
|
|
322
|
+
responses: { 200: json(ListArtifactsResponse, "The artifacts") },
|
|
323
|
+
}, async (c) => {
|
|
324
|
+
const sessionId = c.req.query("sessionId");
|
|
325
|
+
return jsonResponse(200, await listArtifacts(c.env.tenant, sessionId === undefined ? {} : { sessionId }, accessOf(c.get("scope"))));
|
|
326
|
+
});
|
|
327
|
+
tenantRoute(api, OWN, {
|
|
328
|
+
method: "get",
|
|
329
|
+
path: "/v1/artifacts/{artifactId}",
|
|
330
|
+
tags: ["Artifacts"],
|
|
331
|
+
summary: "Get an artifact",
|
|
332
|
+
description: "Its metadata and every version, oldest first.",
|
|
333
|
+
request: { params: artifactId },
|
|
334
|
+
responses: { 200: json(ArtifactView, "The artifact") },
|
|
335
|
+
}, async (c) => jsonResponse(200, await getArtifact(c.env.tenant, c.req.param("artifactId"), accessOf(c.get("scope")))));
|
|
336
|
+
tenantRoute(api, OWN, {
|
|
337
|
+
method: "get",
|
|
338
|
+
path: "/v1/artifacts/{artifactId}/versions/{version}/content",
|
|
339
|
+
tags: ["Artifacts"],
|
|
340
|
+
summary: "Download a version of an artifact",
|
|
341
|
+
description: "Streams the bytes through the Runtime. One `Range: bytes=…` gives `206` with `Content-Range`; several ranges give the whole file. A folder is a `400`: read its tree, its files or its zip.",
|
|
342
|
+
request: { params: versionParam },
|
|
343
|
+
responses: { 200: fileContent("The file"), ...ranged },
|
|
344
|
+
}, async (c) => {
|
|
345
|
+
const ctx = c.env.tenant;
|
|
346
|
+
const { artifact, version } = await artifactContent(ctx, c.req.param("artifactId"), versionNumber(c.req.param("version")), accessOf(c.get("scope")));
|
|
347
|
+
return contentResponse(ctx, fileOf(artifact, version), c.req.raw);
|
|
348
|
+
});
|
|
349
|
+
tenantRoute(api, OWN, {
|
|
350
|
+
method: "get",
|
|
351
|
+
path: "/v1/artifacts/{artifactId}/versions/{version}/tree",
|
|
352
|
+
tags: ["Artifacts"],
|
|
353
|
+
summary: "List a folder version's files",
|
|
354
|
+
description: "The folder version's manifest: every file's path, size, SHA-256 and media type, sorted by path. A file artifact is a `400`.",
|
|
355
|
+
request: { params: versionParam },
|
|
356
|
+
responses: { 200: json(ArtifactTree, "The files") },
|
|
357
|
+
}, async (c) => {
|
|
358
|
+
const { artifact, version, manifest } = await folderContent(c.env.tenant, c.req.param("artifactId"), versionNumber(c.req.param("version")), accessOf(c.get("scope")));
|
|
359
|
+
return jsonResponse(200, {
|
|
360
|
+
artifactId: artifact.id,
|
|
361
|
+
version: version.version,
|
|
362
|
+
entries: manifest.entries,
|
|
363
|
+
});
|
|
364
|
+
});
|
|
365
|
+
tenantRoute(api, OWN, {
|
|
366
|
+
method: "get",
|
|
367
|
+
path: "/v1/artifacts/{artifactId}/versions/{version}/files/{path}",
|
|
368
|
+
tags: ["Artifacts"],
|
|
369
|
+
summary: "Download one file of a folder version",
|
|
370
|
+
description: "Streams the file at `path` (its path in the folder, with `/` percent-encoded as `%2F`) through the Runtime, with Range as for a file artifact.",
|
|
371
|
+
request: {
|
|
372
|
+
params: versionParam.extend({
|
|
373
|
+
path: z.string().meta({ description: "The file's path in the folder, percent-encoded" }),
|
|
374
|
+
}),
|
|
375
|
+
},
|
|
376
|
+
responses: { 200: fileContent("The file"), ...ranged },
|
|
377
|
+
}, async (c) => {
|
|
378
|
+
const ctx = c.env.tenant;
|
|
379
|
+
const { manifest } = await folderContent(ctx, c.req.param("artifactId"), versionNumber(c.req.param("version")), accessOf(c.get("scope")));
|
|
380
|
+
return contentResponse(ctx, folderFile(manifest, c.req.param("path")), c.req.raw);
|
|
381
|
+
});
|
|
382
|
+
tenantRoute(api, OWN, {
|
|
383
|
+
method: "get",
|
|
384
|
+
path: "/v1/artifacts/{artifactId}/versions/{version}/diff",
|
|
385
|
+
tags: ["Artifacts"],
|
|
386
|
+
summary: "Compare two folder versions",
|
|
387
|
+
description: "The files added, removed and changed from version `from` (default: the one before) to this one. Version 1 without `from` lists every file as added.",
|
|
388
|
+
request: {
|
|
389
|
+
params: versionParam,
|
|
390
|
+
query: z.object({
|
|
391
|
+
from: z.string().optional().meta({ description: "The version to compare against" }),
|
|
392
|
+
}),
|
|
393
|
+
},
|
|
394
|
+
responses: { 200: json(ArtifactDiff, "What changed") },
|
|
395
|
+
}, async (c) => {
|
|
396
|
+
const ctx = c.env.tenant;
|
|
397
|
+
const access = accessOf(c.get("scope"));
|
|
398
|
+
const id = c.req.param("artifactId");
|
|
399
|
+
const to = await folderContent(ctx, id, versionNumber(c.req.param("version")), access);
|
|
400
|
+
const query = c.req.query("from");
|
|
401
|
+
const fromVersion = query === undefined
|
|
402
|
+
? to.version.version > 1
|
|
403
|
+
? to.version.version - 1
|
|
404
|
+
: undefined
|
|
405
|
+
: versionNumber(query);
|
|
406
|
+
const from = fromVersion === undefined ? undefined : await folderContent(ctx, id, fromVersion, access);
|
|
407
|
+
return jsonResponse(200, diffManifests(to.artifact.id, from && { version: from.version.version, manifest: from.manifest }, { version: to.version.version, manifest: to.manifest }));
|
|
408
|
+
});
|
|
409
|
+
tenantRoute(api, OWN, {
|
|
410
|
+
method: "get",
|
|
411
|
+
path: "/v1/artifacts/{artifactId}/versions/{version}/zip",
|
|
412
|
+
tags: ["Artifacts"],
|
|
413
|
+
summary: "Download a folder version as a zip",
|
|
414
|
+
description: "Streams the folder's files as one zip archive (deflated, UTF-8 names), read from the Object store as it goes. A file artifact is a `400`.",
|
|
415
|
+
request: { params: versionParam },
|
|
416
|
+
responses: {
|
|
417
|
+
200: {
|
|
418
|
+
description: "The zip archive",
|
|
419
|
+
content: { "application/zip": { schema: z.string().meta({ format: "binary" }) } },
|
|
420
|
+
},
|
|
421
|
+
},
|
|
422
|
+
}, async (c) => {
|
|
423
|
+
const ctx = c.env.tenant;
|
|
424
|
+
const { artifact, version, manifest } = await folderContent(ctx, c.req.param("artifactId"), versionNumber(c.req.param("version")), accessOf(c.get("scope")));
|
|
425
|
+
return zipResponse(ctx, artifact, version, manifest, c.req.raw);
|
|
426
|
+
});
|
|
427
|
+
tenantRoute(api, OWN, {
|
|
428
|
+
method: "post",
|
|
429
|
+
path: "/v1/artifacts/{artifactId}/links",
|
|
430
|
+
tags: ["Artifacts"],
|
|
431
|
+
summary: "Mint a capability link",
|
|
432
|
+
description: "A short-lived path that downloads one version, with Range, with no other credential: for `<img>` tags, UIs and sharing. Signed by the Runtime with the Tenant's signing key; at most 15 minutes; it opens nothing once the artifact is deleted. A folder's link opens its zip, or with `file` that one file.",
|
|
433
|
+
request: {
|
|
434
|
+
params: artifactId,
|
|
435
|
+
body: { required: true, content: { "application/json": { schema: CreateArtifactLinkRequest } } },
|
|
436
|
+
},
|
|
437
|
+
responses: { 200: json(ArtifactLink, "The link") },
|
|
438
|
+
}, async (c) => {
|
|
439
|
+
const ctx = c.env.tenant;
|
|
440
|
+
const request = CreateArtifactLinkRequestSchema.parse(await readJson(c.req.raw));
|
|
441
|
+
const id = c.req.param("artifactId");
|
|
442
|
+
const { artifact, version } = await ctx.store.tx(async (t) => {
|
|
443
|
+
const artifact = await readableArtifact(t, id, accessOf(c.get("scope")));
|
|
444
|
+
return { artifact, version: await versionOf(t, artifact, request.version) };
|
|
445
|
+
});
|
|
446
|
+
if (request.file !== undefined) {
|
|
447
|
+
if (artifact.kind !== "folder")
|
|
448
|
+
fail(400, "file names a file of a folder artifact");
|
|
449
|
+
entryAt(await readManifest(ctx.blobs, version), request.file);
|
|
450
|
+
}
|
|
451
|
+
return jsonResponse(200, await mintArtifactLink(ctx, {
|
|
452
|
+
artifactId: id,
|
|
453
|
+
version: version.version,
|
|
454
|
+
...(request.expiresIn === undefined ? {} : { expiresIn: request.expiresIn }),
|
|
455
|
+
...(request.file === undefined ? {} : { file: request.file }),
|
|
456
|
+
}));
|
|
457
|
+
});
|
|
458
|
+
tenantRoute(api, OWN, {
|
|
459
|
+
method: "delete",
|
|
460
|
+
path: "/v1/artifacts/{artifactId}",
|
|
461
|
+
tags: ["Artifacts"],
|
|
462
|
+
summary: "Delete an artifact",
|
|
463
|
+
description: "Deletes every version and its bytes; its links stop working. A session's artifact gets `artifact.deleted` in its history.",
|
|
464
|
+
request: { params: artifactId },
|
|
465
|
+
responses: { 200: json(DeleteArtifactResponse, "Deleted") },
|
|
466
|
+
}, async (c) => jsonResponse(200, await deleteArtifact(c.env.tenant, c.req.param("artifactId"), accessOf(c.get("scope")))));
|
|
467
|
+
tenantRoute(api, LINK, {
|
|
468
|
+
method: "get",
|
|
469
|
+
path: "/v1/artifact-links/{token}",
|
|
470
|
+
tags: ["Artifacts"],
|
|
471
|
+
summary: "Open a capability link",
|
|
472
|
+
description: "Downloads the version the link names, with Range, until it expires (`401 token_expired`): a file, a folder's file, or a folder's zip. Needs no credential and no `Nylorun-Protocol`.",
|
|
473
|
+
request: { params: z.object({ token: z.string() }) },
|
|
474
|
+
responses: { 200: fileContent("The file"), ...ranged },
|
|
475
|
+
}, async (c) => {
|
|
476
|
+
const ctx = c.env.tenant;
|
|
477
|
+
const { artifactId: id, version, file } = await verifyArtifactLink(ctx, c.req.param("token"));
|
|
478
|
+
const opened = await artifactContent(ctx, id, version, undefined).catch(() => fail(404, "Not found"));
|
|
479
|
+
if (opened.artifact.kind === "file") {
|
|
480
|
+
if (file !== undefined)
|
|
481
|
+
fail(404, "Not found");
|
|
482
|
+
return contentResponse(ctx, fileOf(opened.artifact, opened.version), c.req.raw);
|
|
483
|
+
}
|
|
484
|
+
const manifest = await readManifest(ctx.blobs, opened.version);
|
|
485
|
+
return file === undefined
|
|
486
|
+
? zipResponse(ctx, opened.artifact, opened.version, manifest, c.req.raw)
|
|
487
|
+
: contentResponse(ctx, folderFile(manifest, file), c.req.raw);
|
|
488
|
+
});
|
|
489
|
+
tenantRoute(api, SETTINGS, {
|
|
490
|
+
method: "get",
|
|
491
|
+
path: "/v1/tenant/artifacts",
|
|
492
|
+
tags: ["Tenant"],
|
|
493
|
+
summary: "Get the Tenant's artifact limits",
|
|
494
|
+
responses: { 200: json(TenantArtifactsView, "The limits in force and the bytes used") },
|
|
495
|
+
}, async (c) => jsonResponse(200, await artifactsView(c.env.tenant)));
|
|
496
|
+
tenantRoute(api, SETTINGS, {
|
|
497
|
+
method: "put",
|
|
498
|
+
path: "/v1/tenant/artifacts",
|
|
499
|
+
tags: ["Tenant"],
|
|
500
|
+
summary: "Set the Tenant's artifact limits",
|
|
501
|
+
description: "The largest file one upload may store, and the most bytes all artifacts may hold. Unset limits take the defaults (100 MiB and 10 GiB).",
|
|
502
|
+
request: {
|
|
503
|
+
body: { required: true, content: { "application/json": { schema: PutTenantArtifactsRequest } } },
|
|
504
|
+
},
|
|
505
|
+
responses: { 200: json(TenantArtifactsView, "The limits now in force") },
|
|
506
|
+
}, async (c) => {
|
|
507
|
+
const ctx = c.env.tenant;
|
|
508
|
+
const { requestId: _requestId, ...config } = PutTenantArtifactsRequestSchema.parse(await readJson(c.req.raw));
|
|
509
|
+
await ctx.store.tx((t) => writeArtifactsConfig(t, config));
|
|
510
|
+
return jsonResponse(200, await artifactsView(ctx));
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
async function artifactsView(ctx) {
|
|
514
|
+
return ctx.store.tx(async (t) => ({
|
|
515
|
+
limits: effectiveArtifactLimits(await readArtifactsConfig(t)),
|
|
516
|
+
usedBytes: await t.artifactBytes(),
|
|
517
|
+
}));
|
|
518
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sandboxes as a resource (`/v1/sandboxes/**`, Host feature `sandboxes`): create or find one by
|
|
3
|
+
* id, read it, list them by label, read its lifecycle stream, and delete it. Ids may hold `/`,
|
|
4
|
+
* sent percent-encoded as one path segment (`team-a%2Fproj-42`). A subject token reaches only
|
|
5
|
+
* the ids its `sbx` grants match, and changes them only with `sandboxes:write`.
|
|
6
|
+
*/
|
|
7
|
+
import type { OpenAPIHono } from "@hono/zod-openapi";
|
|
8
|
+
import type { TenantEnv } from "../app.js";
|
|
9
|
+
export declare function sandboxRoutes(api: OpenAPIHono<TenantEnv>): void;
|