@zackbart/connecta 0.25.0 → 0.26.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.
- package/AGENTS.md +6 -5
- package/CHANGELOG.md +271 -0
- package/README.md +32 -15
- package/SECURITY.md +6 -5
- package/bin/connecta.mjs +5 -1
- package/dist/activity.d.ts +10 -3
- package/dist/activity.js +10 -1
- package/dist/artifacts/connector.d.ts +55 -0
- package/dist/artifacts/connector.js +724 -0
- package/dist/artifacts/document.js +76 -0
- package/dist/artifacts/guide.js +140 -0
- package/dist/artifacts/html-scan.js +329 -0
- package/dist/artifacts/json.js +68 -0
- package/dist/artifacts/kv-store.d.ts +20 -0
- package/dist/artifacts/kv-store.js +140 -0
- package/dist/artifacts/markdown.js +449 -0
- package/dist/artifacts/module.d.ts +37 -0
- package/dist/artifacts/module.js +60 -0
- package/dist/artifacts/operations.d.ts +195 -0
- package/dist/artifacts/operations.js +944 -0
- package/dist/artifacts/refresh.d.ts +53 -0
- package/dist/artifacts/refresh.js +235 -0
- package/dist/artifacts/routes.js +194 -0
- package/dist/artifacts/types.d.ts +214 -0
- package/dist/artifacts/types.js +22 -0
- package/dist/artifacts/validate.d.ts +62 -0
- package/dist/artifacts/validate.js +652 -0
- package/dist/artifacts.d.ts +6 -0
- package/dist/artifacts.js +9 -0
- package/dist/catalog-service.js +39 -29
- package/dist/catalog.js +42 -8
- package/dist/connector-access.d.ts +18 -4
- package/dist/connector-access.js +65 -20
- package/dist/connector-caller.d.ts +8 -0
- package/dist/connector-caller.js +19 -0
- package/dist/connectors/remote-mcp.js +16 -2
- package/dist/errors.d.ts +16 -1
- package/dist/errors.js +28 -0
- package/dist/execute.js +427 -70
- package/dist/executor-admission.d.ts +11 -1
- package/dist/executor-admission.js +79 -14
- package/dist/executors/quickjs.js +4 -2
- package/dist/index.d.ts +62 -6
- package/dist/index.js +230 -6
- package/dist/invocation.js +56 -10
- package/dist/meta-tools.js +7 -2
- package/dist/module-contracts.d.ts +21 -1
- package/dist/operator-ui/generated.js +2 -2
- package/dist/operator-ui/model.d.ts +62 -5
- package/dist/operator-ui/view.js +94 -5
- package/dist/program-source.js +116 -0
- package/dist/registry.d.ts +42 -6
- package/dist/registry.js +148 -47
- package/dist/resumable.js +1291 -0
- package/dist/routes/activity.js +6 -69
- package/dist/routes/actor-labels.js +114 -0
- package/dist/routes/credentials.js +27 -5
- package/dist/routes/mcp.js +116 -59
- package/dist/routes/oauth-management.js +22 -9
- package/dist/routes/operator.js +1 -0
- package/dist/routes/shared.d.ts +23 -1
- package/dist/routes/shared.js +20 -2
- package/dist/routes/ui-data.js +2 -1
- package/dist/routes/ui.js +13 -4
- package/dist/run-journal.js +347 -0
- package/dist/runtime/shared-read.js +106 -0
- package/dist/server.js +26 -0
- package/dist/skills.js +27 -8
- package/dist/timeout.js +26 -0
- package/dist/tool-safety.d.ts +35 -0
- package/dist/tool-safety.js +31 -0
- package/dist/types.d.ts +18 -0
- package/dist/ui.d.ts +12 -5
- package/dist/ui.js +32 -6
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/documentation/architecture.md +196 -19
- package/documentation/auth.md +83 -2
- package/documentation/code-mode.md +221 -48
- package/documentation/meta-tools.md +44 -25
- package/ethos.md +12 -10
- package/examples/worker/AGENTS.md +6 -0
- package/examples/worker/README.md +174 -10
- package/examples/worker/src/d1-activity-row.ts +14 -2
- package/examples/worker/src/d1-activity.ts +4 -2
- package/examples/worker/src/index.ts +36 -1
- package/examples/worker/src/r2-artifact-blobs.ts +29 -0
- package/examples/worker/wrangler.jsonc +5 -1
- package/package.json +6 -2
- package/templates/node/AGENTS.md +9 -2
- package/templates/node/README.md +31 -3
- package/templates/node/package.json +1 -1
- package/templates/node/src/index.ts +27 -0
|
@@ -0,0 +1,652 @@
|
|
|
1
|
+
// Static validation for artifact pages and documents.
|
|
2
|
+
//
|
|
3
|
+
// The viewer's CSP and sandbox are what keep a page in its frame; this is the
|
|
4
|
+
// lint that tells an agent, before it saves, what that frame will refuse — a
|
|
5
|
+
// relative URL, a script from an origin the CSP does not name, an element that
|
|
6
|
+
// cannot work there — in a message it can act on from the message alone. Every
|
|
7
|
+
// finding names a line where there is one, quotes the source only through the
|
|
8
|
+
// shared echo bound, and says what to do instead.
|
|
9
|
+
import { boundedEchoText } from "../errors.js";
|
|
10
|
+
import { utf8Bytes } from "../run-journal.js";
|
|
11
|
+
import { lineLocator, scanHtml } from "./html-scan.js";
|
|
12
|
+
import { jsonProblem, scriptSafeJson } from "./json.js";
|
|
13
|
+
import { markdownPage, MarkdownNestingError } from "./markdown.js";
|
|
14
|
+
import { DEFAULT_ARTIFACT_ALLOWLIST, DEFAULT_ARTIFACT_LIMITS, } from "./types.js";
|
|
15
|
+
/** An artifact id: the URL slug. No leading `_`, so `_api` and `_frame` stay free. */
|
|
16
|
+
export const ARTIFACT_ID = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/;
|
|
17
|
+
/** A document name: what a page reads as `artifact.data.<name>`. */
|
|
18
|
+
const DOCUMENT_NAME = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/;
|
|
19
|
+
/** Plain-object document maps must never resolve a name through their prototype. */
|
|
20
|
+
export function validDocumentName(name) {
|
|
21
|
+
return typeof name === "string" && DOCUMENT_NAME.test(name) &&
|
|
22
|
+
!Object.hasOwn(Object.prototype, name) && name !== "prototype";
|
|
23
|
+
}
|
|
24
|
+
const MAX_REPORTED = 20;
|
|
25
|
+
const QUOTE_BYTES = 120;
|
|
26
|
+
/** What the viewer adds around a page beyond its source and documents. */
|
|
27
|
+
const FRAME_OVERHEAD_BYTES = 4096;
|
|
28
|
+
const quote = (text) => boundedEchoText(text, QUOTE_BYTES);
|
|
29
|
+
const count = (n) => n.toLocaleString("en-US");
|
|
30
|
+
const kib = (bytes) => bytes % (1024 * 1024) === 0
|
|
31
|
+
? `${bytes / (1024 * 1024)} MiB`
|
|
32
|
+
: bytes % 1024 === 0
|
|
33
|
+
? `${bytes / 1024} KiB`
|
|
34
|
+
: `${count(bytes)} bytes`;
|
|
35
|
+
/** Fill in defaults; a deployment may only tighten a limit. Throws on anything else. */
|
|
36
|
+
export function resolveLimits(partial = {}) {
|
|
37
|
+
if (partial === null || typeof partial !== "object") {
|
|
38
|
+
throw new TypeError("artifacts: limits must be an object");
|
|
39
|
+
}
|
|
40
|
+
const limits = { ...DEFAULT_ARTIFACT_LIMITS };
|
|
41
|
+
for (const [key, value] of Object.entries(partial)) {
|
|
42
|
+
if (!(key in DEFAULT_ARTIFACT_LIMITS)) {
|
|
43
|
+
throw new TypeError(`artifacts: unknown limit "${key}"`);
|
|
44
|
+
}
|
|
45
|
+
const ceiling = DEFAULT_ARTIFACT_LIMITS[key];
|
|
46
|
+
if (value === undefined)
|
|
47
|
+
continue;
|
|
48
|
+
if (!Number.isInteger(value) || value < 1 || value > ceiling) {
|
|
49
|
+
throw new TypeError(`artifacts: limits.${key} must be a whole number from 1 to ${ceiling}; ` +
|
|
50
|
+
"limits can be tightened, not raised");
|
|
51
|
+
}
|
|
52
|
+
limits[key] = value;
|
|
53
|
+
}
|
|
54
|
+
return limits;
|
|
55
|
+
}
|
|
56
|
+
/** Normalize an allowlist: every entry an exact `https:` origin. Throws on anything else. */
|
|
57
|
+
export function resolveAllowlist(partial = {}) {
|
|
58
|
+
if (partial === null || typeof partial !== "object") {
|
|
59
|
+
throw new TypeError("artifacts: allowlist must be an object");
|
|
60
|
+
}
|
|
61
|
+
const resolved = {
|
|
62
|
+
scripts: DEFAULT_ARTIFACT_ALLOWLIST.scripts,
|
|
63
|
+
styles: DEFAULT_ARTIFACT_ALLOWLIST.styles,
|
|
64
|
+
fonts: DEFAULT_ARTIFACT_ALLOWLIST.fonts,
|
|
65
|
+
};
|
|
66
|
+
for (const [key, list] of Object.entries(partial)) {
|
|
67
|
+
if (!(key in resolved)) {
|
|
68
|
+
throw new TypeError(`artifacts: unknown allowlist field "${key}"`);
|
|
69
|
+
}
|
|
70
|
+
if (list === undefined)
|
|
71
|
+
continue;
|
|
72
|
+
if (!Array.isArray(list)) {
|
|
73
|
+
throw new TypeError(`artifacts: allowlist.${key} must be an array of origins`);
|
|
74
|
+
}
|
|
75
|
+
const origins = list.map((entry) => {
|
|
76
|
+
let origin;
|
|
77
|
+
try {
|
|
78
|
+
origin = typeof entry === "string" ? new URL(entry).origin : undefined;
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
origin = undefined;
|
|
82
|
+
}
|
|
83
|
+
if (origin === undefined || origin !== entry || !origin.startsWith("https://")) {
|
|
84
|
+
throw new TypeError(`artifacts: allowlist.${key} entries must be exact https: origins ` +
|
|
85
|
+
"like https://cdn.jsdelivr.net, with no path or trailing slash");
|
|
86
|
+
}
|
|
87
|
+
return origin;
|
|
88
|
+
});
|
|
89
|
+
resolved[key] = Object.freeze([...new Set(origins)]);
|
|
90
|
+
}
|
|
91
|
+
return resolved;
|
|
92
|
+
}
|
|
93
|
+
/** The URL with the characters a browser ignores removed, for scheme checks. */
|
|
94
|
+
function normalizedUrl(value) {
|
|
95
|
+
// A URL parser strips leading and trailing C0 controls and spaces, and
|
|
96
|
+
// tabs and newlines anywhere, so `java\tscript:` is still a scheme.
|
|
97
|
+
let start = 0;
|
|
98
|
+
let end = value.length;
|
|
99
|
+
while (start < end && value.charCodeAt(start) <= 0x20)
|
|
100
|
+
start++;
|
|
101
|
+
while (end > start && value.charCodeAt(end - 1) <= 0x20)
|
|
102
|
+
end--;
|
|
103
|
+
return value.slice(start, end).replace(/[\t\n\r]/g, "");
|
|
104
|
+
}
|
|
105
|
+
function schemeOf(value) {
|
|
106
|
+
return /^([A-Za-z][A-Za-z0-9+.-]*):/.exec(normalizedUrl(value))?.[1]?.toLowerCase();
|
|
107
|
+
}
|
|
108
|
+
function httpsOrigin(value) {
|
|
109
|
+
const normalized = normalizedUrl(value);
|
|
110
|
+
if (schemeOf(normalized) !== "https")
|
|
111
|
+
return undefined;
|
|
112
|
+
try {
|
|
113
|
+
const url = new URL(normalized);
|
|
114
|
+
return { origin: url.origin, credentials: Boolean(url.username || url.password), url };
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* The URLs in a `srcset`, split the way the standard splits them: a URL is a
|
|
122
|
+
* run of non-space characters, so the commas inside a `data:` URL stay put.
|
|
123
|
+
*/
|
|
124
|
+
function srcsetUrls(value) {
|
|
125
|
+
const urls = [];
|
|
126
|
+
let i = 0;
|
|
127
|
+
while (i < value.length) {
|
|
128
|
+
while (i < value.length && /[\s,]/.test(value[i] ?? ""))
|
|
129
|
+
i++;
|
|
130
|
+
const start = i;
|
|
131
|
+
while (i < value.length && !/\s/.test(value[i] ?? ""))
|
|
132
|
+
i++;
|
|
133
|
+
let url = value.slice(start, i);
|
|
134
|
+
if (url.endsWith(",")) {
|
|
135
|
+
url = url.replace(/,+$/, "");
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
// Skip the descriptors, up to the comma that ends this candidate.
|
|
139
|
+
let depth = 0;
|
|
140
|
+
while (i < value.length && (value[i] !== "," || depth > 0)) {
|
|
141
|
+
if (value[i] === "(")
|
|
142
|
+
depth++;
|
|
143
|
+
else if (value[i] === ")")
|
|
144
|
+
depth = Math.max(0, depth - 1);
|
|
145
|
+
i++;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (url)
|
|
149
|
+
urls.push(url);
|
|
150
|
+
}
|
|
151
|
+
return urls;
|
|
152
|
+
}
|
|
153
|
+
/** Whether a CDN URL names an exact version. `@latest` and bare package paths do not. */
|
|
154
|
+
function pinned(url) {
|
|
155
|
+
const path = url.pathname.replace(/%40/gi, "@");
|
|
156
|
+
if (/@latest\b/i.test(path))
|
|
157
|
+
return false;
|
|
158
|
+
if (url.hostname === "cdnjs.cloudflare.com") {
|
|
159
|
+
return /^\/ajax\/libs\/[^/]+\/\d/.test(path);
|
|
160
|
+
}
|
|
161
|
+
return /@v?\d/.test(path);
|
|
162
|
+
}
|
|
163
|
+
const PINNED_EXAMPLE = "https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js";
|
|
164
|
+
const EMBEDDING = {
|
|
165
|
+
iframe: "pages cannot embed other pages",
|
|
166
|
+
frame: "pages cannot embed other pages",
|
|
167
|
+
frameset: "pages cannot embed other pages",
|
|
168
|
+
portal: "pages cannot embed other pages",
|
|
169
|
+
object: "pages cannot embed plugins or other documents",
|
|
170
|
+
embed: "pages cannot embed plugins or other documents",
|
|
171
|
+
applet: "pages cannot embed plugins or other documents",
|
|
172
|
+
form: "pages cannot submit forms; use inputs without a <form> and read them from a script",
|
|
173
|
+
base: "it would change how every URL on the page resolves",
|
|
174
|
+
};
|
|
175
|
+
const FORBIDDEN_ATTRIBUTES = {
|
|
176
|
+
srcdoc: "it embeds another document",
|
|
177
|
+
ping: "it sends a request when a link is followed, and pages have no network",
|
|
178
|
+
formaction: "pages cannot submit forms",
|
|
179
|
+
action: "pages cannot submit forms",
|
|
180
|
+
};
|
|
181
|
+
/** Attributes that fetch what they name. */
|
|
182
|
+
const RESOURCE_ATTRIBUTES = new Set(["src", "poster", "background", "data", "lowsrc", "dynsrc"]);
|
|
183
|
+
/** Attributes a reader follows. */
|
|
184
|
+
const NAVIGATION_ATTRIBUTES = new Set(["cite", "longdesc"]);
|
|
185
|
+
const NETWORK = /\b(fetch\s*\(|XMLHttpRequest\b|WebSocket\b|EventSource\b|sendBeacon\b|importScripts\s*\()/;
|
|
186
|
+
const DATA_READS = [
|
|
187
|
+
/\bartifact\s*\.\s*data\s*\.\s*([A-Za-z_$][A-Za-z0-9_$]*)/g,
|
|
188
|
+
/\bartifact\s*\.\s*data\s*\[\s*["']([^"'\\]{1,64})["']\s*\]/g,
|
|
189
|
+
];
|
|
190
|
+
/** Every static finding for a page's source. */
|
|
191
|
+
export function checkView(input, context) {
|
|
192
|
+
const findings = { errors: [], warnings: [] };
|
|
193
|
+
const { source, kind } = input;
|
|
194
|
+
const { limits } = context;
|
|
195
|
+
if (typeof source !== "string" || !source.trim()) {
|
|
196
|
+
findings.errors.push({
|
|
197
|
+
code: "E_EMPTY",
|
|
198
|
+
severity: "error",
|
|
199
|
+
message: kind === "markdown"
|
|
200
|
+
? "The source is empty. Write the Markdown the page should show."
|
|
201
|
+
: 'The source is empty. Write a complete page, e.g. <!doctype html><main id="artifact-root">…</main>.',
|
|
202
|
+
});
|
|
203
|
+
return findings;
|
|
204
|
+
}
|
|
205
|
+
const bytes = utf8Bytes(source);
|
|
206
|
+
if (bytes > limits.sourceBytes) {
|
|
207
|
+
findings.errors.push({
|
|
208
|
+
code: "E_TOO_LARGE",
|
|
209
|
+
severity: "error",
|
|
210
|
+
message: `The source is ${count(bytes)} bytes; the limit is ${count(limits.sourceBytes)} (${kib(limits.sourceBytes)}). ` +
|
|
211
|
+
"Move data into documents, or trim the page.",
|
|
212
|
+
});
|
|
213
|
+
return findings;
|
|
214
|
+
}
|
|
215
|
+
if (kind === "markdown") {
|
|
216
|
+
let rendered;
|
|
217
|
+
try {
|
|
218
|
+
rendered = utf8Bytes(markdownPage(source, input.title ?? "")) + input.dataBytes + FRAME_OVERHEAD_BYTES;
|
|
219
|
+
}
|
|
220
|
+
catch (error) {
|
|
221
|
+
if (!(error instanceof MarkdownNestingError))
|
|
222
|
+
throw error;
|
|
223
|
+
findings.errors.push({ code: "E_NESTING", severity: "error", message: error.message });
|
|
224
|
+
return findings;
|
|
225
|
+
}
|
|
226
|
+
if (rendered > limits.renderedBytes) {
|
|
227
|
+
findings.errors.push({
|
|
228
|
+
code: "E_TOO_LARGE",
|
|
229
|
+
severity: "error",
|
|
230
|
+
message: `The rendered page would be ${count(rendered)} bytes; the limit is ${count(limits.renderedBytes)} ` +
|
|
231
|
+
`(${kib(limits.renderedBytes)}). Shorten the Markdown.`,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
checkMarkdownImages(source, findings);
|
|
235
|
+
return findings;
|
|
236
|
+
}
|
|
237
|
+
const rendered = bytes + input.dataBytes + FRAME_OVERHEAD_BYTES;
|
|
238
|
+
if (rendered > limits.renderedBytes) {
|
|
239
|
+
findings.errors.push({
|
|
240
|
+
code: "E_TOO_LARGE",
|
|
241
|
+
severity: "error",
|
|
242
|
+
message: `The page plus its documents would be ${count(rendered)} bytes; the limit is ` +
|
|
243
|
+
`${count(limits.renderedBytes)} (${kib(limits.renderedBytes)}). Trim the page or its documents.`,
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
checkHtml(input, context, findings);
|
|
247
|
+
return findings;
|
|
248
|
+
}
|
|
249
|
+
function checkMarkdownImages(source, findings) {
|
|
250
|
+
const locate = lineLocator(source);
|
|
251
|
+
const image = /!\[[^\]]*\]\(\s*<?([^)\s>]+)/g;
|
|
252
|
+
let match;
|
|
253
|
+
while ((match = image.exec(source)) !== null) {
|
|
254
|
+
const url = match[1] ?? "";
|
|
255
|
+
if (/^data:image\//i.test(url))
|
|
256
|
+
continue;
|
|
257
|
+
const { line } = locate(match.index);
|
|
258
|
+
findings.warnings.push({
|
|
259
|
+
code: "W_IMAGE",
|
|
260
|
+
severity: "warning",
|
|
261
|
+
line,
|
|
262
|
+
message: `Line ${line}: the image ${quote(url)} will not load; pages have no network. ` +
|
|
263
|
+
"Embed it as a data:image/png;base64,… URL.",
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
function checkHtml(input, context, findings) {
|
|
268
|
+
const { source } = input;
|
|
269
|
+
const { allowlist } = context;
|
|
270
|
+
const tokens = scanHtml(source);
|
|
271
|
+
const locate = lineLocator(source);
|
|
272
|
+
const at = (offset) => locate(offset);
|
|
273
|
+
const error = (code, offset, message) => {
|
|
274
|
+
if (offset === undefined) {
|
|
275
|
+
findings.errors.push({ code, severity: "error", message });
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
const { line, column } = at(offset);
|
|
279
|
+
findings.errors.push({ code, severity: "error", line, column, message: `Line ${line}: ${message}` });
|
|
280
|
+
};
|
|
281
|
+
const warn = (code, offset, message) => {
|
|
282
|
+
if (offset === undefined) {
|
|
283
|
+
findings.warnings.push({ code, severity: "warning", message });
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
const { line, column } = at(offset);
|
|
287
|
+
findings.warnings.push({ code, severity: "warning", line, column, message: `Line ${line}: ${message}` });
|
|
288
|
+
};
|
|
289
|
+
const roots = [];
|
|
290
|
+
let sawDoctype = false;
|
|
291
|
+
let sawElement = false;
|
|
292
|
+
const externalLinks = [];
|
|
293
|
+
const unpinned = new Set();
|
|
294
|
+
const missingNames = new Map();
|
|
295
|
+
let networkWarned = 0;
|
|
296
|
+
const scriptOriginHelp = allowlist.scripts.length
|
|
297
|
+
? ` (${allowlist.scripts.join(", ")}). Load it from one of those, pinned, e.g. ${PINNED_EXAMPLE}.`
|
|
298
|
+
: ". This deployment allows no external scripts. Inline the script, or ask the operator to trust its origin.";
|
|
299
|
+
const styleOriginHelp = allowlist.styles.length
|
|
300
|
+
? ` (${allowlist.styles.join(", ")}). Inline the CSS in a <style> element, or load it from one of those.`
|
|
301
|
+
: ". This deployment allows no external stylesheets. Inline the CSS, or ask the operator to trust its origin.";
|
|
302
|
+
const styleImportHelp = allowlist.styles.length
|
|
303
|
+
? ` (${allowlist.styles.join(", ")}). Inline the CSS, or import from one of those.`
|
|
304
|
+
: ". This deployment allows no external stylesheets. Inline the CSS, or ask the operator to trust its origin.";
|
|
305
|
+
const checkResourceUrl = (tag, attr) => {
|
|
306
|
+
const value = attr.value;
|
|
307
|
+
const shown = `<${tag} ${attr.name}="${quote(value)}">`;
|
|
308
|
+
const trimmed = normalizedUrl(value);
|
|
309
|
+
if (!trimmed) {
|
|
310
|
+
error("E_URL", attr.offset, `${shown} is empty. Remove the attribute or embed the content as a data: URL.`);
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
const scheme = schemeOf(trimmed);
|
|
314
|
+
if (trimmed.startsWith("#") && (attr.name === "href" || attr.name === "xlink:href"))
|
|
315
|
+
return;
|
|
316
|
+
if (scheme === "data" || scheme === "blob")
|
|
317
|
+
return;
|
|
318
|
+
if (scheme === undefined) {
|
|
319
|
+
error("E_URL", attr.offset, `${shown} is relative; pages have no files beside them. Embed it as a data: URL.`);
|
|
320
|
+
}
|
|
321
|
+
else if (scheme === "https" || scheme === "http") {
|
|
322
|
+
error("E_URL", attr.offset, `${shown} loads from the network, and pages have no network. Embed it as a data: URL.`);
|
|
323
|
+
}
|
|
324
|
+
else {
|
|
325
|
+
error("E_URL", attr.offset, `${shown} uses ${scheme}:, which pages cannot load. Embed it as a data: URL.`);
|
|
326
|
+
}
|
|
327
|
+
};
|
|
328
|
+
const checkLinkUrl = (tag, attr) => {
|
|
329
|
+
const value = attr.value;
|
|
330
|
+
const trimmed = normalizedUrl(value);
|
|
331
|
+
if (!trimmed || trimmed.startsWith("#"))
|
|
332
|
+
return;
|
|
333
|
+
const shown = `<${tag} ${attr.name}="${quote(value)}">`;
|
|
334
|
+
const scheme = schemeOf(trimmed);
|
|
335
|
+
if (scheme === "https" || scheme === "mailto") {
|
|
336
|
+
externalLinks.push(attr.offset);
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
if (scheme === undefined) {
|
|
340
|
+
error("E_URL", attr.offset, `${shown} is relative; pages have no files beside them. Link an https: URL or a #fragment.`);
|
|
341
|
+
}
|
|
342
|
+
else if (scheme === "http") {
|
|
343
|
+
error("E_URL", attr.offset, `${shown} uses http:. Link the https: URL instead.`);
|
|
344
|
+
}
|
|
345
|
+
else if (scheme === "javascript") {
|
|
346
|
+
error("E_URL", attr.offset, `${shown} uses javascript:. Attach a click handler from a <script> instead.`);
|
|
347
|
+
}
|
|
348
|
+
else {
|
|
349
|
+
error("E_URL", attr.offset, `${shown} uses ${scheme}:. Links may be https:, mailto:, or a #fragment.`);
|
|
350
|
+
}
|
|
351
|
+
};
|
|
352
|
+
const checkCss = (css, offset, where) => {
|
|
353
|
+
const urls = /url\(\s*(?:"([^"]*)"|'([^']*)'|([^)\s]*))\s*\)/gi;
|
|
354
|
+
// An @import is judged once, as an import; blank it (keeping offsets) so
|
|
355
|
+
// its url() is not judged again as a resource.
|
|
356
|
+
const resources = css.replace(/@import[^;]*;?/gi, (statement) => " ".repeat(statement.length));
|
|
357
|
+
let match;
|
|
358
|
+
while ((match = urls.exec(resources)) !== null) {
|
|
359
|
+
const value = match[1] ?? match[2] ?? match[3] ?? "";
|
|
360
|
+
const trimmed = normalizedUrl(value);
|
|
361
|
+
if (trimmed.startsWith("#"))
|
|
362
|
+
continue;
|
|
363
|
+
const scheme = schemeOf(trimmed);
|
|
364
|
+
if (scheme === "data" || scheme === "blob")
|
|
365
|
+
continue;
|
|
366
|
+
const https = httpsOrigin(trimmed);
|
|
367
|
+
if (https && allowlist.fonts.includes(https.origin))
|
|
368
|
+
continue;
|
|
369
|
+
const position = offset + match.index;
|
|
370
|
+
error("E_URL", position, scheme === undefined
|
|
371
|
+
? `${where} url(${quote(value)}) is relative; pages have no files beside them. Use a data: URL.`
|
|
372
|
+
: `${where} url(${quote(value)}) loads from the network, and pages have no network. Use a data: URL.`);
|
|
373
|
+
}
|
|
374
|
+
const imports = /@import\s+(?:url\(\s*)?["']?([^"')\s;]+)/gi;
|
|
375
|
+
while ((match = imports.exec(css)) !== null) {
|
|
376
|
+
const value = match[1] ?? "";
|
|
377
|
+
const https = httpsOrigin(value);
|
|
378
|
+
if (https && !https.credentials && allowlist.styles.includes(https.origin))
|
|
379
|
+
continue;
|
|
380
|
+
error("E_STYLE_ORIGIN", offset + match.index, `${where} @import ${quote(value)} loads from outside the style allowlist${styleImportHelp}`);
|
|
381
|
+
}
|
|
382
|
+
};
|
|
383
|
+
const checkScriptSource = (text, offset) => {
|
|
384
|
+
if (networkWarned < 3) {
|
|
385
|
+
const network = NETWORK.exec(text);
|
|
386
|
+
if (network) {
|
|
387
|
+
networkWarned++;
|
|
388
|
+
warn("W_NETWORK", offset + network.index, `the script calls ${network[1]?.replace(/\s*\($/, "")}, but pages have no network. Read window.artifact.data instead.`);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
for (const pattern of DATA_READS) {
|
|
392
|
+
pattern.lastIndex = 0;
|
|
393
|
+
let match;
|
|
394
|
+
while ((match = pattern.exec(text)) !== null) {
|
|
395
|
+
const name = match[1] ?? "";
|
|
396
|
+
if (!input.documentNames.includes(name) && !missingNames.has(name)) {
|
|
397
|
+
missingNames.set(name, offset + match.index);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
};
|
|
402
|
+
for (const token of tokens) {
|
|
403
|
+
if (token.type === "doctype") {
|
|
404
|
+
if (!sawElement)
|
|
405
|
+
sawDoctype = true;
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
if (token.type === "text") {
|
|
409
|
+
if (token.rawOf === "style") {
|
|
410
|
+
checkCss(source.slice(token.start, token.end), token.start, "<style>");
|
|
411
|
+
}
|
|
412
|
+
else if (token.rawOf === "script") {
|
|
413
|
+
checkScriptSource(source.slice(token.start, token.end), token.start);
|
|
414
|
+
}
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
if (token.type !== "start")
|
|
418
|
+
continue;
|
|
419
|
+
sawElement = true;
|
|
420
|
+
const tag = token.name;
|
|
421
|
+
const attr = (name) => token.attrs.find((candidate) => candidate.name === name);
|
|
422
|
+
const embedding = EMBEDDING[tag];
|
|
423
|
+
if (embedding) {
|
|
424
|
+
error("E_ELEMENT", token.start, `<${tag}> is not allowed; ${embedding}. Remove it.`);
|
|
425
|
+
}
|
|
426
|
+
if (tag === "meta" && attr("http-equiv")) {
|
|
427
|
+
error("E_ELEMENT", token.start, "<meta http-equiv> is not allowed; the viewer sets the page's headers. Remove it.");
|
|
428
|
+
}
|
|
429
|
+
for (const attribute of token.attrs) {
|
|
430
|
+
const name = attribute.name;
|
|
431
|
+
if (name === "id" && attribute.value === "artifact-root")
|
|
432
|
+
roots.push(token.start);
|
|
433
|
+
// An element that is refused whole needs no second finding per URL.
|
|
434
|
+
if (embedding)
|
|
435
|
+
continue;
|
|
436
|
+
const forbidden = FORBIDDEN_ATTRIBUTES[name];
|
|
437
|
+
if (forbidden) {
|
|
438
|
+
error("E_ATTRIBUTE", attribute.offset, `the ${name} attribute on <${tag}> is not allowed; ${forbidden}. Remove it.`);
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
if (name === "style")
|
|
442
|
+
checkCss(attribute.value, attribute.offset, `<${tag} style>`);
|
|
443
|
+
if (name === "srcset" || name === "imagesrcset") {
|
|
444
|
+
const bad = srcsetUrls(attribute.value).find((candidate) => {
|
|
445
|
+
const scheme = schemeOf(candidate);
|
|
446
|
+
return scheme !== "data" && scheme !== "blob";
|
|
447
|
+
});
|
|
448
|
+
if (bad !== undefined) {
|
|
449
|
+
error("E_ATTRIBUTE", attribute.offset, `<${tag} ${name}> names ${quote(bad)}, which cannot load; pages have no network. ` +
|
|
450
|
+
"Use a single src with a data: URL.");
|
|
451
|
+
}
|
|
452
|
+
continue;
|
|
453
|
+
}
|
|
454
|
+
if (tag === "script" || tag === "link")
|
|
455
|
+
continue;
|
|
456
|
+
if (name === "href" || name === "xlink:href") {
|
|
457
|
+
if (tag === "a" || tag === "area")
|
|
458
|
+
checkLinkUrl(tag, attribute);
|
|
459
|
+
else
|
|
460
|
+
checkResourceUrl(tag, attribute);
|
|
461
|
+
continue;
|
|
462
|
+
}
|
|
463
|
+
if (RESOURCE_ATTRIBUTES.has(name))
|
|
464
|
+
checkResourceUrl(tag, attribute);
|
|
465
|
+
else if (NAVIGATION_ATTRIBUTES.has(name))
|
|
466
|
+
checkLinkUrl(tag, attribute);
|
|
467
|
+
}
|
|
468
|
+
if (tag === "script") {
|
|
469
|
+
const src = attr("src") ?? attr("href") ?? attr("xlink:href");
|
|
470
|
+
if (src) {
|
|
471
|
+
const https = httpsOrigin(src.value);
|
|
472
|
+
const shown = `<script src="${quote(src.value)}">`;
|
|
473
|
+
if (!https || https.credentials || !allowlist.scripts.includes(https.origin)) {
|
|
474
|
+
error("E_SCRIPT_ORIGIN", src.offset, `${shown} ${!https
|
|
475
|
+
? schemeOf(src.value) === undefined
|
|
476
|
+
? "is relative, and pages have no files beside them"
|
|
477
|
+
: "is not an https: URL"
|
|
478
|
+
: https.credentials
|
|
479
|
+
? "carries credentials"
|
|
480
|
+
: "loads from outside the allowlist"}${scriptOriginHelp}`);
|
|
481
|
+
}
|
|
482
|
+
else if (!pinned(https.url) && !unpinned.has(https.url.href)) {
|
|
483
|
+
unpinned.add(https.url.href);
|
|
484
|
+
warn("W_UNPINNED", src.offset, `${shown} names no exact version, so the page can change or break when the library does. Pin one, e.g. ${PINNED_EXAMPLE}.`);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
if (tag === "link") {
|
|
489
|
+
const href = attr("href");
|
|
490
|
+
const rels = (attr("rel")?.value ?? "").toLowerCase().split(/\s+/).filter(Boolean);
|
|
491
|
+
if (!href)
|
|
492
|
+
continue;
|
|
493
|
+
const https = httpsOrigin(href.value);
|
|
494
|
+
const shown = `<link rel="${quote(rels.join(" "))}" href="${quote(href.value)}">`;
|
|
495
|
+
const stylesheet = rels.includes("stylesheet");
|
|
496
|
+
const hint = rels.length > 0 && rels.every((rel) => rel === "preconnect" || rel === "dns-prefetch");
|
|
497
|
+
if (!stylesheet && !hint) {
|
|
498
|
+
error("E_STYLE_ORIGIN", href.offset, `${shown} is not allowed; only rel="stylesheet" and rel="preconnect" links load. Remove it.`);
|
|
499
|
+
continue;
|
|
500
|
+
}
|
|
501
|
+
const allowed = stylesheet ? allowlist.styles : [...allowlist.styles, ...allowlist.fonts];
|
|
502
|
+
if (!https || https.credentials || !allowed.includes(https.origin)) {
|
|
503
|
+
error("E_STYLE_ORIGIN", href.offset, `${shown} loads from outside the style allowlist${styleOriginHelp}`);
|
|
504
|
+
}
|
|
505
|
+
else if (stylesheet && https.url.hostname !== "fonts.googleapis.com" && !pinned(https.url) && !unpinned.has(https.url.href)) {
|
|
506
|
+
unpinned.add(https.url.href);
|
|
507
|
+
warn("W_UNPINNED", href.offset, `${shown} names no exact version, so the page can change when the library does. Pin one.`);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
if (roots.length === 0) {
|
|
512
|
+
error("E_ROOT", undefined, 'No element has id="artifact-root". Wrap the page in <main id="artifact-root">…</main>.');
|
|
513
|
+
}
|
|
514
|
+
else if (roots.length > 1) {
|
|
515
|
+
const lines = roots.slice(0, 5).map((offset) => at(offset).line);
|
|
516
|
+
error("E_ROOT", roots[1], `id="artifact-root" appears ${roots.length} times (lines ${lines.join(", ")}${roots.length > 5 ? ", …" : ""}). ` +
|
|
517
|
+
"Keep exactly one.");
|
|
518
|
+
}
|
|
519
|
+
if (!sawDoctype) {
|
|
520
|
+
warn("W_NO_DOCTYPE", undefined, "The page has no <!doctype html>, so it renders in quirks mode. Start it with <!doctype html>.");
|
|
521
|
+
}
|
|
522
|
+
for (const [name, offset] of missingNames) {
|
|
523
|
+
const have = input.documentNames.length ? input.documentNames.join(", ") : "none";
|
|
524
|
+
warn("W_DATA_NAME", offset, `the script reads artifact.data.${quote(name)}, but the page gets no document named ${quote(name)} ` +
|
|
525
|
+
`(it has: ${have}). Save it with artifacts.set_documents, or read a name that exists.`);
|
|
526
|
+
}
|
|
527
|
+
const firstLink = externalLinks[0];
|
|
528
|
+
if (firstLink !== undefined) {
|
|
529
|
+
warn("W_EXTERNAL_LINK", firstLink, `${externalLinks.length === 1 ? "a link leaves" : `${externalLinks.length} links leave`} the page when clicked: ` +
|
|
530
|
+
"they navigate the artifact's own frame, and pages cannot open new windows. Show the URL as text if readers need it elsewhere.");
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
/** Check one document's name and value, and serialize it the way it is stored. */
|
|
534
|
+
export function checkDocument(name, value, limits) {
|
|
535
|
+
if (!validDocumentName(name)) {
|
|
536
|
+
return {
|
|
537
|
+
bytes: 0,
|
|
538
|
+
error: {
|
|
539
|
+
code: "E_DOCUMENT",
|
|
540
|
+
severity: "error",
|
|
541
|
+
message: `Document name ${quote(String(name))} is not allowed. Use letters, digits, and _, starting with a letter ` +
|
|
542
|
+
"or _, at most 64 characters, and avoid reserved object property names, so a page can read it as artifact.data.<name>.",
|
|
543
|
+
},
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
const problem = jsonProblem(value, limits.jsonDepth);
|
|
547
|
+
if (problem !== undefined) {
|
|
548
|
+
return {
|
|
549
|
+
bytes: 0,
|
|
550
|
+
error: {
|
|
551
|
+
code: "E_DOCUMENT",
|
|
552
|
+
severity: "error",
|
|
553
|
+
message: `Document '${name}' is not storable JSON: ${problem}.`,
|
|
554
|
+
},
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
const text = scriptSafeJson(value);
|
|
558
|
+
const bytes = utf8Bytes(text);
|
|
559
|
+
if (bytes > limits.documentBytes) {
|
|
560
|
+
return {
|
|
561
|
+
bytes,
|
|
562
|
+
error: {
|
|
563
|
+
code: "E_TOO_LARGE",
|
|
564
|
+
severity: "error",
|
|
565
|
+
message: `Document '${name}' is ${count(bytes)} bytes serialized; one document may be at most ` +
|
|
566
|
+
`${count(limits.documentBytes)} (${kib(limits.documentBytes)}). Split it across documents or drop fields the page does not show.`,
|
|
567
|
+
},
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
return { text, bytes };
|
|
571
|
+
}
|
|
572
|
+
/** The collective document bounds: how many, and how large together. */
|
|
573
|
+
export function checkDocumentTotals(live, totalBytes, limits) {
|
|
574
|
+
if (live > limits.documents) {
|
|
575
|
+
return {
|
|
576
|
+
code: "E_TOO_LARGE",
|
|
577
|
+
severity: "error",
|
|
578
|
+
message: `The artifact would have ${live} documents; the limit is ${limits.documents}. ` +
|
|
579
|
+
"Combine related data into fewer documents, or remove one by setting it to null.",
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
if (totalBytes > limits.totalDocumentBytes) {
|
|
583
|
+
return {
|
|
584
|
+
code: "E_TOO_LARGE",
|
|
585
|
+
severity: "error",
|
|
586
|
+
message: `Its documents would total ${count(totalBytes)} bytes; the limit is ${count(limits.totalDocumentBytes)} ` +
|
|
587
|
+
`(${kib(limits.totalDocumentBytes)}). Drop data the page does not show.`,
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
return undefined;
|
|
591
|
+
}
|
|
592
|
+
/** Sort findings by position, cap errors at 20, and report what was cut. */
|
|
593
|
+
export function finish(findings) {
|
|
594
|
+
const order = (a, b) => (a.line ?? 0) - (b.line ?? 0) || (a.column ?? 0) - (b.column ?? 0);
|
|
595
|
+
const errors = [...findings.errors].sort(order);
|
|
596
|
+
const warnings = [...findings.warnings].sort(order).slice(0, MAX_REPORTED);
|
|
597
|
+
const validation = {
|
|
598
|
+
ok: errors.length === 0,
|
|
599
|
+
errors: errors.slice(0, MAX_REPORTED),
|
|
600
|
+
warnings,
|
|
601
|
+
};
|
|
602
|
+
if (errors.length > MAX_REPORTED)
|
|
603
|
+
validation.errorsOmitted = errors.length - MAX_REPORTED;
|
|
604
|
+
return validation;
|
|
605
|
+
}
|
|
606
|
+
/**
|
|
607
|
+
* Validate a page and its documents without saving anything: the same static
|
|
608
|
+
* checks every artifact write runs. `ok` is exactly "no errors"; warnings are
|
|
609
|
+
* advice.
|
|
610
|
+
*/
|
|
611
|
+
export function validateArtifact(input, options = {}) {
|
|
612
|
+
const context = {
|
|
613
|
+
limits: resolveLimits(options.limits),
|
|
614
|
+
allowlist: resolveAllowlist(options.allowlist),
|
|
615
|
+
};
|
|
616
|
+
return validateWithContext(input, context);
|
|
617
|
+
}
|
|
618
|
+
/** `validateArtifact` against an already-resolved context. */
|
|
619
|
+
export function validateWithContext(input, context) {
|
|
620
|
+
const findings = { errors: [], warnings: [] };
|
|
621
|
+
if (input.kind !== "html" && input.kind !== "markdown") {
|
|
622
|
+
findings.errors.push({
|
|
623
|
+
code: "E_KIND",
|
|
624
|
+
severity: "error",
|
|
625
|
+
message: 'kind must be "html" or "markdown".',
|
|
626
|
+
});
|
|
627
|
+
return finish(findings);
|
|
628
|
+
}
|
|
629
|
+
const documents = input.documents ?? {};
|
|
630
|
+
let dataBytes = 0;
|
|
631
|
+
let live = 0;
|
|
632
|
+
for (const [name, value] of Object.entries(documents)) {
|
|
633
|
+
const checked = checkDocument(name, value, context.limits);
|
|
634
|
+
if (checked.error)
|
|
635
|
+
findings.errors.push(checked.error);
|
|
636
|
+
dataBytes += checked.bytes;
|
|
637
|
+
live++;
|
|
638
|
+
}
|
|
639
|
+
const totals = checkDocumentTotals(live, dataBytes, context.limits);
|
|
640
|
+
if (totals)
|
|
641
|
+
findings.errors.push(totals);
|
|
642
|
+
const view = checkView({
|
|
643
|
+
kind: input.kind,
|
|
644
|
+
source: input.source,
|
|
645
|
+
...(input.title === undefined ? {} : { title: input.title }),
|
|
646
|
+
documentNames: Object.keys(documents),
|
|
647
|
+
dataBytes,
|
|
648
|
+
}, context);
|
|
649
|
+
findings.errors.push(...view.errors);
|
|
650
|
+
findings.warnings.push(...view.warnings);
|
|
651
|
+
return finish(findings);
|
|
652
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { artifacts, type ArtifactsOptions, type RefreshableArtifacts } from "./artifacts/module.js";
|
|
2
|
+
export type { RefreshOutcome } from "./artifacts/refresh.js";
|
|
3
|
+
export type { ArtifactRenderCheck, ArtifactRenderCheckInput, ArtifactRenderCheckResult, } from "./artifacts/connector.js";
|
|
4
|
+
export { kvArtifactStore, type KvArtifactStoreOptions } from "./artifacts/kv-store.js";
|
|
5
|
+
export { validateArtifact, type ValidateArtifactInput, type ValidateArtifactOptions, } from "./artifacts/validate.js";
|
|
6
|
+
export { ARTIFACT_RUNS_RETAINED, DEFAULT_ARTIFACT_ALLOWLIST, DEFAULT_ARTIFACT_LIMITS, type ArtifactActor, type ArtifactAllowlist, type ArtifactBlobStore, type ArtifactHeadRecord, type ArtifactRefreshState, type ArtifactRefreshSchedule, type ArtifactIssue, type ArtifactKind, type ArtifactLimits, type ArtifactRunRecord, type ArtifactStore, type ArtifactStream, type ArtifactValidation, type ArtifactVersionOp, type ArtifactVersionRecord, } from "./artifacts/types.js";
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// @zackbart/connecta/artifacts — the optional artifacts module.
|
|
2
|
+
//
|
|
3
|
+
// Team pages over stored JSON, reached by agents through a built-in
|
|
4
|
+
// connector. Nothing here is reachable from the root entry: a deployment that
|
|
5
|
+
// never imports this subpath carries none of it and does none of its work.
|
|
6
|
+
export { artifacts } from "./artifacts/module.js";
|
|
7
|
+
export { kvArtifactStore } from "./artifacts/kv-store.js";
|
|
8
|
+
export { validateArtifact, } from "./artifacts/validate.js";
|
|
9
|
+
export { ARTIFACT_RUNS_RETAINED, DEFAULT_ARTIFACT_ALLOWLIST, DEFAULT_ARTIFACT_LIMITS, } from "./artifacts/types.js";
|