@hyperframes/share 0.9.28-alpha.0 → 0.9.28

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 (53) hide show
  1. package/LICENSE +190 -0
  2. package/README.md +1 -58
  3. package/dist/index.d.mts +0 -1
  4. package/dist/projectLock.d.mts +2 -2
  5. package/dist/projectSnapshot.d.mts +1 -3
  6. package/dist/reviewClient.d.mts +16 -13
  7. package/dist/reviewExport.d.mts +1 -11
  8. package/dist/reviewUpload.d.mts +0 -2
  9. package/package.json +1 -1
  10. package/scripts/copy-types.mjs +0 -3
  11. package/src/index.mjs +0 -6
  12. package/src/projectLock.mjs +2 -9
  13. package/src/projectSnapshot.mjs +4 -11
  14. package/src/reviewClient.mjs +83 -48
  15. package/src/reviewExport.mjs +37 -145
  16. package/src/reviewUpload.mjs +16 -18
  17. package/dist/shareSources.d.mts +0 -24
  18. package/dist/sources/adapterTypes.d.ts +0 -39
  19. package/dist/sources/api.d.mts +0 -29
  20. package/dist/sources/capture.d.mts +0 -24
  21. package/dist/sources/contract.d.mts +0 -2
  22. package/dist/sources/errors.d.mts +0 -24
  23. package/dist/sources/limits.d.mts +0 -12
  24. package/dist/sources/manifest.d.mts +0 -23
  25. package/dist/sources/pathPolicy.d.mts +0 -25
  26. package/dist/sources/projectState.d.mts +0 -9
  27. package/dist/sources/store.d.mts +0 -3
  28. package/dist/sources/transfer.d.mts +0 -45
  29. package/dist/sources/types.d.ts +0 -353
  30. package/dist/sources/unicode.d.mts +0 -15
  31. package/dist/sources/upload.d.mts +0 -9
  32. package/dist/sources/uploadFiles.d.mts +0 -31
  33. package/dist/sources/wire.d.mts +0 -21
  34. package/src/shareSources.mjs +0 -236
  35. package/src/sources/adapterTypes.d.ts +0 -39
  36. package/src/sources/api.mjs +0 -114
  37. package/src/sources/capture.mjs +0 -262
  38. package/src/sources/contract/LICENSE-Unicode.txt +0 -39
  39. package/src/sources/contract/path-policy.json +0 -311
  40. package/src/sources/contract/unicode-15.1.0.json +0 -6179
  41. package/src/sources/contract.mjs +0 -13
  42. package/src/sources/errors.mjs +0 -77
  43. package/src/sources/limits.mjs +0 -48
  44. package/src/sources/manifest.mjs +0 -154
  45. package/src/sources/pathPolicy.mjs +0 -197
  46. package/src/sources/projectState.mjs +0 -59
  47. package/src/sources/store.mjs +0 -82
  48. package/src/sources/transfer.mjs +0 -196
  49. package/src/sources/types.d.ts +0 -353
  50. package/src/sources/unicode.mjs +0 -163
  51. package/src/sources/upload.mjs +0 -201
  52. package/src/sources/uploadFiles.mjs +0 -263
  53. package/src/sources/wire.mjs +0 -205
@@ -1,13 +0,0 @@
1
- // @ts-check
2
- // Load the bundled snapshot policy and Unicode registries from JSON or Brotli.
3
- import { existsSync, readFileSync } from "node:fs";
4
- import { brotliDecompressSync } from "node:zlib";
5
-
6
- /** A registry's parsed JSON. @param {string} name a file in contract/, such as "path-policy.json" @returns {any} */
7
- export function readContract(name) {
8
- const packed = new URL(`./contract/${name}.br`, import.meta.url);
9
- const text = existsSync(packed)
10
- ? brotliDecompressSync(readFileSync(packed)).toString("utf8")
11
- : readFileSync(new URL(`./contract/${name}`, import.meta.url), "utf8");
12
- return JSON.parse(text);
13
- }
@@ -1,77 +0,0 @@
1
- // @ts-check
2
- // Map sync API error codes to stable client error kinds.
3
-
4
- /** @typedef {import("./types.js").SyncErrorKind} SyncErrorKind */
5
-
6
- /** @type {Record<string, SyncErrorKind>} */
7
- const KIND_BY_CODE = {
8
- hyperframes_composition_sync_not_enabled: "not-enabled",
9
- hf_subscription_required: "plan-required",
10
- hyperframes_composition_sync_limit_reached: "limit-reached",
11
- byok_not_supported: "byok",
12
- resource_access_denied: "no-write-role",
13
- forbidden: "forbidden",
14
- hyperframes_composition_not_found: "composition-not-found",
15
- resource_not_found: "not-found",
16
- hyperframes_composition_upload_too_large: "too-large",
17
- hyperframes_composition_upload_reference_invalid: "reference-invalid",
18
- hyperframes_composition_upload_not_pending: "upload-not-pending",
19
- hyperframes_composition_active_uploads_exceeded: "active-uploads-exceeded",
20
- hyperframes_composition_upload_file_not_writable: "file-not-writable",
21
- conflict: "idempotency-key-reused",
22
- request_in_progress: "request-in-progress",
23
- hyperframes_composition_snapshot_not_downloadable: "snapshot-not-downloadable",
24
- hyperframes_composition_conflict_not_open: "conflict-not-open",
25
- hyperframes_composition_download_expired: "download-expired",
26
- invalid_parameter: "invalid-parameter",
27
- rate_limit_exceeded: "rate-limited",
28
- service_unavailable: "unavailable",
29
- };
30
-
31
- /** A sync route's refusal or failure. `status` is 0 when the server was never reached. */
32
- export class SyncApiError extends Error {
33
- /** @param {SyncErrorKind} kind @param {{ status?: number, code?: string | null, param?: string | null,
34
- * requestId?: string | null, retryAfterMs?: number | null, detail?: string }} [about] */
35
- constructor(kind, about = {}) {
36
- super(`composition sync: ${kind}${about.code ? ` (${about.code})` : ""}`);
37
- this.name = "SyncApiError";
38
- this.kind = kind;
39
- this.status = about.status ?? 0;
40
- this.code = about.code ?? null;
41
- this.param = about.param ?? null;
42
- this.requestId = about.requestId ?? null;
43
- this.retryAfterMs = about.retryAfterMs ?? null;
44
- this.detail = about.detail ?? null;
45
- }
46
- }
47
-
48
- /** The kind for an HTTP status whose code this client does not know. @param {number} status
49
- * @returns {SyncErrorKind} */
50
- function kindOfStatus(status) {
51
- if (status === 404) return "not-found";
52
- if (status === 429) return "rate-limited";
53
- if (status === 503) return "unavailable";
54
- if (status >= 500) return "server-error";
55
- return "refused";
56
- }
57
-
58
- /** The typed error for a refusal: the envelope's code when it is one of the sync routes', else the status's.
59
- * @param {Response} reply @returns {Promise<SyncApiError>} */
60
- export async function refusalOf(reply) {
61
- const payload = await reply.json().catch(() => null);
62
- /** @type {{ code?: unknown, param?: unknown, request_id?: unknown, message?: unknown } | undefined} */
63
- const error = payload?.error;
64
- const code = typeof error?.code === "string" ? error.code : null;
65
- const seconds = Number(reply.headers.get("retry-after") ?? Number.NaN);
66
- return new SyncApiError((code && KIND_BY_CODE[code]) || kindOfStatus(reply.status), {
67
- status: reply.status,
68
- code,
69
- param: typeof error?.param === "string" ? error.param : null,
70
- requestId:
71
- typeof error?.request_id === "string"
72
- ? error.request_id
73
- : reply.headers.get("x-heygen-request-id"),
74
- retryAfterMs: seconds >= 0 ? seconds * 1000 : null,
75
- ...(typeof error?.message === "string" ? { detail: error.message } : {}),
76
- });
77
- }
@@ -1,48 +0,0 @@
1
- // @ts-check
2
- // Check the workspace upload limits before hashing or sending files.
3
-
4
- const MIB = 1024 * 1024;
5
- const GIB = 1024 * MIB;
6
-
7
- /** @typedef {import("./types.js").Limits} Limits @typedef {import("./types.js").LimitName} LimitName */
8
-
9
- /** @type {Limits} */
10
- export const V1_LIMITS = Object.freeze({
11
- maxFiles: 10_000,
12
- maxFileBytes: 5 * GIB,
13
- maxProjectBytes: 10 * GIB,
14
- maxManifestBytes: 8 * MIB,
15
- });
16
-
17
- /** How many of the biggest files an over-limit answer names. */
18
- const NAMED = 10;
19
-
20
- /** A manifest's file count, largest file, total and canonical size; every listed file counts, reused ones too.
21
- * @param {readonly { size: number }[]} files @param {number} manifestBytes */
22
- const measure = (files, manifestBytes) => ({
23
- fileCount: files.length,
24
- largestFileBytes: files.reduce((most, file) => Math.max(most, file.size), 0),
25
- projectBytes: files.reduce((sum, file) => sum + file.size, 0),
26
- manifestBytes,
27
- });
28
-
29
- /** The first limit broken, in the server's fixed order (file count, file size, project size, manifest size), with
30
- * the biggest files first; null when the push fits. @param {readonly { path: string, size: number }[]} files
31
- * @param {number} manifestBytes @param {Limits} [limits] @returns {import("./types.js").OverLimit | null} */
32
- export function overLimit(files, manifestBytes, limits = V1_LIMITS) {
33
- const size = measure(files, manifestBytes);
34
- /** @type {[LimitName, number, number][]} */
35
- const checks = [
36
- ["file_count", size.fileCount, limits.maxFiles],
37
- ["file_size", size.largestFileBytes, limits.maxFileBytes],
38
- ["project_size", size.projectBytes, limits.maxProjectBytes],
39
- ["manifest_size", size.manifestBytes, limits.maxManifestBytes],
40
- ];
41
- const broken = checks.find(([, actual, maximum]) => actual > maximum);
42
- if (!broken) return null;
43
- const biggest = [...files]
44
- .sort((a, b) => b.size - a.size || (a.path < b.path ? -1 : 1))
45
- .slice(0, NAMED)
46
- .map(({ path, size: bytes }) => ({ path, size: bytes }));
47
- return { limit: broken[0], actual: broken[1], maximum: broken[2], biggest };
48
- }
@@ -1,154 +0,0 @@
1
- // @ts-check
2
- // Serialize snapshots to canonical UTF-8 bytes so clients and the server compute the same digest.
3
- import { createHash } from "node:crypto";
4
- import { PathViolation, checkInventoryPaths, validatePath } from "./pathPolicy.mjs";
5
-
6
- const FORMAT_VERSION = 1;
7
- export const ENTRYPOINT = "index.html";
8
- const MAX_SAFE_SIZE = Number.MAX_SAFE_INTEGER;
9
- const SHA256 = /^[0-9a-f]{64}$/;
10
-
11
- /** @typedef {import("./types.js").ManifestFile} ManifestFile @typedef {import("./types.js").Violation} Violation
12
- * @typedef {import("./types.js").ManifestDocument} ManifestDocument @typedef {import("./types.js").Manifest} Manifest */
13
-
14
- /** Why a manifest was refused; `code` is the first violation's, in the server's order. */
15
- export class ManifestInvalid extends Error {
16
- /** @param {Violation[]} violations */
17
- constructor(violations) {
18
- super(violations.map((violation) => violation.code).join(", "));
19
- this.name = "ManifestInvalid";
20
- this.violations = violations;
21
- this.code = violations[0]?.code ?? "invalid_schema";
22
- }
23
- }
24
-
25
- /** @param {unknown} value @returns {value is Record<string, unknown>} */
26
- const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
27
- /** @param {unknown} error @returns {Violation} */
28
- const violationOf = (error) => {
29
- if (!(error instanceof PathViolation)) throw error;
30
- return { code: error.code, path: error.path, ...(error.rule ? { rule: error.rule } : {}) };
31
- };
32
-
33
- /** A schema violation for each field `fields` does not name.
34
- * @param {Record<string, unknown>} value @param {readonly string[]} fields @param {Violation[]} out */
35
- function unknownFields(value, fields, out) {
36
- for (const key of Object.keys(value))
37
- if (!fields.includes(key)) out.push({ code: "invalid_schema" });
38
- }
39
-
40
- /** @param {unknown} raw @param {Violation[]} out @param {boolean} stored @returns {ManifestFile | null} */
41
- function fileOf(raw, out, stored) {
42
- if (!isObject(raw)) return (out.push({ code: "invalid_schema" }), null);
43
- const before = out.length;
44
- const { path, sha256, size } = raw;
45
- if (typeof path !== "string") out.push({ code: "invalid_schema" });
46
- else {
47
- try {
48
- validatePath(path, stored);
49
- } catch (error) {
50
- out.push(violationOf(error));
51
- }
52
- }
53
- checkBlob(sha256, size, out);
54
- unknownFields(raw, ["path", "sha256", "size"], out);
55
- return out.length === before
56
- ? { path: String(path), sha256: String(sha256), size: Number(size) }
57
- : null;
58
- }
59
-
60
- /** @param {unknown} sha256 @param {unknown} size @param {Violation[]} out */
61
- function checkBlob(sha256, size, out) {
62
- if (typeof sha256 !== "string") out.push({ code: "invalid_schema" });
63
- else if (!SHA256.test(sha256)) out.push({ code: "invalid_sha256" });
64
- if (typeof size !== "number" || !Number.isInteger(size)) out.push({ code: "invalid_schema" });
65
- else if (size < 0 || size > MAX_SAFE_SIZE) out.push({ code: "invalid_size" });
66
- }
67
-
68
- /** UTF-8 byte order, which is code point order; JavaScript's default sort compares UTF-16 units instead.
69
- * @param {ManifestFile} a @param {ManifestFile} b */
70
- const byUtf8 = (a, b) => Buffer.compare(Buffer.from(a.path, "utf8"), Buffer.from(b.path, "utf8"));
71
-
72
- /** The files in canonical order, or the inventory's first violation. @param {ManifestFile[]} files
73
- * @param {Violation[]} out */
74
- function canonicalInventory(files, out) {
75
- const ordered = [...files].sort(byUtf8);
76
- /** @type {Map<string, number>} */
77
- const sizeByDigest = new Map();
78
- for (const file of ordered) {
79
- const size = sizeByDigest.get(file.sha256) ?? file.size;
80
- sizeByDigest.set(file.sha256, size);
81
- if (size !== file.size)
82
- return (out.push({ code: "inconsistent_digest_size", path: file.path }), null);
83
- }
84
- try {
85
- checkInventoryPaths(ordered.map((file) => file.path));
86
- } catch (error) {
87
- return (out.push(violationOf(error)), null);
88
- }
89
- return ordered;
90
- }
91
-
92
- /** @param {unknown} raw @param {Violation[]} out @param {boolean} stored */
93
- function filesOf(raw, out, stored) {
94
- if (!Array.isArray(raw)) return (out.push({ code: "invalid_schema" }), null);
95
- const before = out.length;
96
- const files = raw.map((item) => fileOf(item, out, stored));
97
- if (out.length !== before) return null;
98
- return canonicalInventory(/** @type {ManifestFile[]} */ (files), out);
99
- }
100
-
101
- /** The canonical bytes of a valid document. @param {ManifestDocument} document */
102
- export function canonicalBytes(document) {
103
- const files = document.files
104
- .map(
105
- ({ path, sha256, size }) =>
106
- `{"path":${JSON.stringify(path)},"sha256":${JSON.stringify(sha256)},"size":${size}}`,
107
- )
108
- .join(",");
109
- const text = `{"entrypoint":${JSON.stringify(document.entrypoint)},"files":[${files}],"format_version":${document.format_version}}`;
110
- return Buffer.from(text, "utf8");
111
- }
112
-
113
- /** Validates a manifest document as the server does; throws ManifestInvalid. @param {unknown} raw
114
- * @param {boolean} [stored] @returns {Manifest} */
115
- export function parseManifest(raw, stored = false) {
116
- if (!isObject(raw)) throw new ManifestInvalid([{ code: "invalid_schema" }]);
117
- /** @type {Violation[]} */
118
- const out = [];
119
- const { format_version: version, entrypoint } = raw;
120
- if (!("format_version" in raw) || typeof version !== "number" || !Number.isInteger(version))
121
- out.push({ code: "invalid_schema" });
122
- else if (version !== FORMAT_VERSION) out.push({ code: "unsupported_format_version" });
123
- if (typeof entrypoint !== "string") out.push({ code: "invalid_schema" });
124
- else if (entrypoint !== ENTRYPOINT) out.push({ code: "invalid_entrypoint" });
125
- const files = filesOf(raw.files, out, stored);
126
- unknownFields(raw, ["format_version", "entrypoint", "files"], out);
127
- if (out.length) throw new ManifestInvalid(out);
128
- const ordered = /** @type {ManifestFile[]} */ (files);
129
- if (!ordered.some((file) => file.path === ENTRYPOINT))
130
- throw new ManifestInvalid([{ code: "entrypoint_not_listed" }]);
131
- /** @type {ManifestDocument} */
132
- const document = { format_version: FORMAT_VERSION, entrypoint: ENTRYPOINT, files: ordered };
133
- const canonical = canonicalBytes(document);
134
- return { document, canonical, sha256: createHash("sha256").update(canonical).digest("hex") };
135
- }
136
-
137
- /** Stored manifest bytes, which must already be canonical so their hash is the snapshot's manifest_sha256.
138
- * @param {Uint8Array} body @returns {Manifest} */
139
- export function parseCanonicalManifest(body) {
140
- let document;
141
- try {
142
- document = JSON.parse(new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(body));
143
- } catch {
144
- throw new ManifestInvalid([{ code: "invalid_json" }]);
145
- }
146
- const manifest = parseManifest(document);
147
- if (!manifest.canonical.equals(Buffer.from(body)))
148
- throw new ManifestInvalid([{ code: "not_canonical" }]);
149
- return manifest;
150
- }
151
-
152
- /** A published snapshot or local base can retain v3 paths; its bytes and digest remain immutable.
153
- * @param {unknown} raw @returns {Manifest} */
154
- export const parseStoredManifest = (raw) => parseManifest(raw, true);
@@ -1,197 +0,0 @@
1
- // @ts-check
2
- // Validate snapshot paths in the order defined by the bundled portable-source-v4 policy.
3
- import { readContract } from "./contract.mjs";
4
- import { UNICODE_VERSION, allAssigned, comparisonKey, nfc } from "./unicode.mjs";
5
-
6
- const PATH_POLICY_VERSION = "portable-source-v4";
7
-
8
- /** @typedef {import("./types.js").PathRule} PathRule @typedef {import("./types.js").PolicyDocument} PolicyDocument */
9
-
10
- /** A path the policy refuses: `code` is the golden vectors' shared vocabulary, `rule` the excluding rule's id. */
11
- export class PathViolation extends Error {
12
- /** @param {string} code @param {string} path @param {string} [rule] */
13
- constructor(code, path, rule) {
14
- super(`${code}: ${JSON.stringify(path)}${rule ? ` (${rule})` : ""}`);
15
- this.name = "PathViolation";
16
- this.code = code;
17
- this.path = path;
18
- this.rule = rule ?? null;
19
- }
20
- }
21
-
22
- const ASCII = /^\p{ASCII}*$/u;
23
-
24
- /** @param {string} pattern */
25
- const whole = (pattern) => new RegExp(`^(?:${pattern})$`, "su");
26
- /** @param {string} text */
27
- const escaped = (text) => text.replace(/[\\\]^-]/g, "\\$&");
28
-
29
- /** @param {PolicyDocument} document */
30
- function compile(document) {
31
- if (document.policy_version !== PATH_POLICY_VERSION)
32
- throw new Error(`path policy is ${document.policy_version}, expected ${PATH_POLICY_VERSION}`);
33
- if (document.unicode.version !== UNICODE_VERSION)
34
- throw new Error(
35
- `path policy pins Unicode ${document.unicode.version}, the table is ${UNICODE_VERSION}`,
36
- );
37
- if (document.rules.at(-1)?.regex !== ".+")
38
- throw new Error("path policy must end with a catch-all rule, so every path is classified");
39
- for (const rule of document.rules)
40
- if ((rule.server_action === "allow") !== (rule.client_action === "include"))
41
- throw new Error(`path rule ${rule.id}: the server allows exactly what clients include`);
42
- const limits = document.path;
43
- return {
44
- limits,
45
- invalidCharacter: new RegExp(
46
- `[\\x00-\\x1f\\x7f-\\x9f\\ud800-\\udfff${escaped(limits.forbidden_characters)}]`,
47
- "u",
48
- ),
49
- reservedSegment: whole(limits.reserved_segment_regex),
50
- canonicalRoots: new Map(
51
- limits.canonical_spelling_roots.map((root) => [comparisonKey(root), root]),
52
- ),
53
- rules: document.rules.map((rule) => ({ rule, pattern: whole(rule.regex) })),
54
- storedRules: document.rules.map((rule) => ({
55
- rule,
56
- pattern: whole(
57
- rule.id === "agent-client-state"
58
- ? document.stored_manifest.legacy_agent_state_regex
59
- : rule.regex,
60
- ),
61
- })),
62
- prefilter: new Set(document.client_prefilter.names),
63
- symlinkPrefixes: document.client_symlinks.skip_prefixes,
64
- };
65
- }
66
-
67
- /** @type {ReturnType<typeof compile> | null} */
68
- let compiled = null;
69
- /** The policy, read and compiled when a path is first checked: an app that never syncs never reads it. */
70
- const policy = () => (compiled ??= compile(readContract("path-policy.json")));
71
-
72
- /** Every segment replaced by its comparison key. @param {string} path */
73
- const foldedPath = (path) => path.split("/").map(comparisonKey).join("/");
74
-
75
- /** The first rule matching a path's folded spelling. @param {string} path @returns {PathRule} */
76
- export function classifyPath(path) {
77
- const folded = foldedPath(path);
78
- const found = policy().rules.find(({ pattern }) => pattern.test(folded));
79
- if (!found) throw new Error("the path policy's catch-all rule did not match");
80
- return found.rule;
81
- }
82
-
83
- /** Checks 1 to 10 of the registry's `path.checks`: the ones about the string itself. @param {string} path */
84
- function checkSpelling(path) {
85
- const { limits, invalidCharacter } = policy();
86
- if (!path) throw new PathViolation("path_empty", path);
87
- if (invalidCharacter.test(path)) throw new PathViolation("path_invalid_character", path);
88
- if (!ASCII.test(path) && !allAssigned(path))
89
- throw new PathViolation("path_unassigned_code_point", path);
90
- if (nfc(path) !== path) throw new PathViolation("path_not_nfc", path);
91
- if (Buffer.byteLength(path, "utf8") > limits.max_path_bytes)
92
- throw new PathViolation("path_too_long", path);
93
- if (path.startsWith("/")) throw new PathViolation("path_not_relative", path);
94
- return checkSegments(path, limits);
95
- }
96
- /** @param {string} path @param {PolicyDocument["path"]} limits */
97
- function checkSegments(path, limits) {
98
- const segments = path.split("/");
99
- if (segments.some((segment) => !segment)) throw new PathViolation("path_empty_segment", path);
100
- if (segments.some((segment) => segment === "." || segment === ".."))
101
- throw new PathViolation("path_dot_segment", path);
102
- if (segments.some((segment) => Buffer.byteLength(segment, "utf8") > limits.max_segment_bytes))
103
- throw new PathViolation("segment_too_long", path);
104
- if (segments.some((segment) => segment.endsWith(".") || segment.endsWith(" ")))
105
- throw new PathViolation("segment_trailing_dot_or_space", path);
106
- return segments;
107
- }
108
-
109
- /** `path` unchanged when every client can store it and the policy lets it travel; throws PathViolation otherwise,
110
- * naming the first failing check in the registry's order. @param {string} path
111
- * @param {boolean} [stored] */
112
- export function validatePath(path, stored = false) {
113
- const segments = checkSpelling(path);
114
- const keys = segments.map(comparisonKey);
115
- const { reservedSegment, canonicalRoots } = policy();
116
- const rules = stored ? policy().storedRules : policy().rules;
117
- if (keys.some((key) => reservedSegment.test(key))) throw new PathViolation("reserved_name", path);
118
- const root = canonicalRoots.get(keys[0] ?? "");
119
- if (root !== undefined && segments[0] !== root)
120
- throw new PathViolation("reserved_path_alias", path);
121
- const folded = keys.join("/");
122
- const found = rules.find(({ pattern }) => pattern.test(folded));
123
- if (!found) throw new Error("the path policy's catch-all rule did not match");
124
- if (found.rule.server_action === "reject")
125
- throw new PathViolation("excluded_path", path, found.rule.id);
126
- if (!found.pattern.test(path))
127
- throw new PathViolation("reserved_path_alias", path, found.rule.id);
128
- return path;
129
- }
130
-
131
- /** Refuses duplicates, and any two paths (or a path and a directory prefix) one filesystem would store as one name.
132
- * Each path must already pass validatePath. @param {readonly string[]} paths */
133
- export function checkInventoryPaths(paths) {
134
- const seen = { spellingByKey: new Map(), fileKeys: new Set(), directoryKeys: new Set() };
135
- for (const path of paths) {
136
- const segments = path.split("/");
137
- const keys = segments.map(comparisonKey);
138
- for (let depth = 1; depth <= segments.length; depth++)
139
- notePrefix(seen, path, {
140
- prefix: segments.slice(0, depth).join("/"),
141
- key: keys.slice(0, depth).join("/"),
142
- isFile: depth === segments.length,
143
- });
144
- }
145
- }
146
-
147
- /** One prefix of `path`, against every prefix before it, in the server's order of checks.
148
- * @param {{ spellingByKey: Map<string, string>, fileKeys: Set<string>, directoryKeys: Set<string> }} seen
149
- * @param {string} path @param {{ prefix: string, key: string, isFile: boolean }} at */
150
- function notePrefix(seen, path, { prefix, key, isFile }) {
151
- const spelling = seen.spellingByKey.get(key) ?? prefix;
152
- seen.spellingByKey.set(key, spelling);
153
- if (isFile && seen.fileKeys.has(key))
154
- throw new PathViolation(spelling === prefix ? "duplicate_path" : "path_collision", path);
155
- if (spelling !== prefix) throw new PathViolation("path_collision", path);
156
- if (isFile ? seen.directoryKeys.has(key) : seen.fileKeys.has(key))
157
- throw new PathViolation("file_directory_collision", path);
158
- (isFile ? seen.fileKeys : seen.directoryKeys).add(key);
159
- }
160
-
161
- /** The registry's `client_prefilter`: a name (macOS's `Icon\r`) skipped before any path check. @param {string} name */
162
- export const prefiltered = (name) => policy().prefilter.has(name);
163
-
164
- /** The registry's `client_symlinks`: a link under `assets/podcast/` is skipped instead of failing the capture.
165
- * @param {string} path */
166
- export const skippedSymlink = (path) =>
167
- policy().symlinkPrefixes.some((prefix) => foldedPath(path).startsWith(prefix));
168
-
169
- /** The literal text a rule's regex starts with, up to its first metacharacter. @param {string} regex */
170
- function literalPrefix(regex) {
171
- let text = "";
172
- for (let i = 0; i < regex.length; i++) {
173
- const char = regex[i] ?? "";
174
- if (char === "\\" && /[./-]/.test(regex[i + 1] ?? "")) text += regex[++i];
175
- else if (/[\w/-]/.test(char)) text += char;
176
- else break;
177
- }
178
- return text;
179
- }
180
-
181
- /** Whether no file under folder `dir` can be listed, so a capture need not open it: its first matching rule
182
- * excludes the folder and all below it, and no include rule before that one can reach inside. @param {string} dir */
183
- export function excludedFolder(dir) {
184
- const folded = foldedPath(dir);
185
- const at = policy().rules.findIndex(
186
- ({ pattern, rule }) =>
187
- rule.client_action !== "include" && pattern.test(folded) && pattern.test(`${folded}/x`),
188
- );
189
- if (at < 0) return false;
190
- return policy()
191
- .rules.slice(0, at)
192
- .every(({ rule }) => {
193
- if (rule.client_action !== "include") return true;
194
- const prefix = literalPrefix(rule.regex);
195
- return !(prefix.startsWith(`${folded}/`) || `${folded}/`.startsWith(prefix));
196
- });
197
- }
@@ -1,59 +0,0 @@
1
- // @ts-check
2
- // What the HyperFrames CLI keeps in a project's .hyperframes folder that a capture must honour: render outputs written
3
- // outside renders/ (left out of every manifest), an open agent turn and a held project lock (no capture until both
4
- // end). The formats are the pinned hyperframes CLI's, version 1 of render-outputs.json.
5
- import { readFile } from "node:fs/promises";
6
- import { join } from "node:path";
7
-
8
- /** A predicate for the paths the project's render-outputs.json lists, and everything under them; none when the file
9
- * is missing or not version 1. @param {string} root @returns {Promise<(rel: string) => boolean>} */
10
- export async function readRenderOutputs(root) {
11
- const raw = await readFile(join(root, ".hyperframes", "render-outputs.json"), "utf8").catch(
12
- () => null,
13
- );
14
- /** @type {{ version?: unknown, paths?: unknown } | null} */
15
- let record = null;
16
- try {
17
- record = raw === null ? null : JSON.parse(raw);
18
- } catch {
19
- record = null;
20
- }
21
- const paths =
22
- record?.version === 1 && Array.isArray(record.paths)
23
- ? record.paths
24
- .filter((path) => typeof path === "string")
25
- .map((path) => path.replace(/\\/g, "/"))
26
- : [];
27
- const listed = new Set(paths);
28
- return (rel) => listed.has(rel) || paths.some((path) => rel.startsWith(`${path}/`));
29
- }
30
-
31
- /** Whether a pid still runs: EPERM is a process another user owns. @param {number} pid */
32
- function alive(pid) {
33
- try {
34
- process.kill(pid, 0);
35
- return true;
36
- } catch (error) {
37
- return /** @type {NodeJS.ErrnoException} */ (error).code === "EPERM";
38
- }
39
- }
40
-
41
- /** An open agent turn (its history-turn.json exists) or a project lock whose holder still runs.
42
- * @param {string} root */
43
- export async function settledState(root) {
44
- const dir = join(root, ".hyperframes");
45
- const turn = await readFile(join(dir, "history-turn.json")).then(
46
- () => true,
47
- () => false,
48
- );
49
- const lock = await readFile(join(dir, "project.lock"), "utf8").catch(() => null);
50
- /** @type {{ pid?: unknown } | null} */
51
- let holder = null;
52
- try {
53
- holder = lock === null ? null : JSON.parse(lock);
54
- } catch {
55
- holder = null;
56
- }
57
- const locked = lock !== null && (typeof holder?.pid !== "number" || alive(holder.pid));
58
- return { agentTurnOpen: turn, projectLocked: locked };
59
- }
@@ -1,82 +0,0 @@
1
- // @ts-check
2
- import { createHash } from "node:crypto";
3
- import { constants } from "node:fs";
4
- import { lstat, open } from "node:fs/promises";
5
- import { join } from "node:path";
6
- import { errorCode, mkdirInside, writeJsonAtomic } from "../storage.mjs";
7
- import { parseStoredManifest } from "./manifest.mjs";
8
-
9
- /** @param {unknown} value @returns {value is Record<string, unknown>} */
10
- const object = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
11
- /** @param {unknown} raw @param {import("./types.js").BindingKey} key @returns {import("./types.js").Binding} */
12
- function bindingOf(raw, key) {
13
- if (
14
- !object(raw) ||
15
- raw.version !== 1 ||
16
- raw.backend !== key.backend ||
17
- raw.account !== key.account ||
18
- raw.projectPath !== key.projectPath ||
19
- typeof raw.compositionId !== "string" ||
20
- typeof raw.ownerSpaceId !== "string"
21
- )
22
- throw new Error(
23
- "Source binding belongs to another account, backend or project. Reset explicitly before sharing this copy.",
24
- );
25
- checkSnapshots(raw);
26
- return /** @type {import("./types.js").Binding} */ (/** @type {unknown} */ (raw));
27
- }
28
-
29
- /** @param {Record<string, unknown>} raw */
30
- function checkSnapshots(raw) {
31
- if (
32
- raw.base !== null &&
33
- (!object(raw.base) ||
34
- typeof raw.base.snapshotId !== "string" ||
35
- parseStoredManifest(raw.base.manifest).sha256 !== raw.base.manifestSha256)
36
- )
37
- throw new Error("Invalid source base snapshot.");
38
- if (
39
- raw.inflight !== null &&
40
- (!object(raw.inflight) ||
41
- typeof raw.inflight.idempotencyKey !== "string" ||
42
- parseStoredManifest(raw.inflight.manifest).sha256 !== raw.inflight.manifestSha256)
43
- )
44
- throw new Error("Invalid pending source upload.");
45
- }
46
-
47
- /** Project-local state is excluded by the portable source policy. Account/root mismatches fail closed, including copied folders.
48
- * @returns {import("./adapterTypes.js").SourceStore} */
49
- export function createProjectShareSourceStore() {
50
- /** @param {import("./types.js").BindingKey} key */
51
- const location = async (key) => {
52
- const dir = await mkdirInside(key.projectPath, ".hyperframes");
53
- const environment = createHash("sha256").update(key.backend).digest("hex").slice(0, 16);
54
- return join(dir, `share-sync.${environment}.json`);
55
- };
56
- return {
57
- read: async (key) => {
58
- const file = await location(key);
59
- const handle = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW).catch((error) => {
60
- if (errorCode(error) === "ENOENT") return null;
61
- throw error;
62
- });
63
- if (!handle) return null;
64
- try {
65
- if (!(await handle.stat()).isFile())
66
- throw new Error("Source binding must be a regular file.");
67
- return bindingOf(JSON.parse(await handle.readFile("utf8")), key);
68
- } finally {
69
- await handle.close();
70
- }
71
- },
72
- write: async (binding) => {
73
- const file = await location(binding);
74
- const held = await lstat(file).catch((error) => {
75
- if (errorCode(error) === "ENOENT") return null;
76
- throw error;
77
- });
78
- if (held && !held.isFile()) throw new Error("Source binding must be a regular file.");
79
- await writeJsonAtomic(file, binding);
80
- },
81
- };
82
- }