@1agh/maude 0.47.0 → 0.49.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/README.md +7 -6
- package/apps/studio/acp/bridge.ts +8 -1
- package/apps/studio/acp/plugin-bootstrap.ts +15 -1
- package/apps/studio/api.ts +18 -2
- package/apps/studio/assets-s3.ts +291 -0
- package/apps/studio/build.ts +1 -1
- package/apps/studio/client/export-center.jsx +7 -6
- package/apps/studio/client/github.js +11 -4
- package/apps/studio/collab/origins.ts +150 -0
- package/apps/studio/collab/protocol.ts +36 -9
- package/apps/studio/collab/room.ts +124 -0
- package/apps/studio/context.ts +9 -0
- package/apps/studio/dist/client.bundle.js +282 -282
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/runtime/REMOTION-LICENSE.md +1 -1
- package/apps/studio/examples/perf-100-artboards.tsx +1 -1
- package/apps/studio/exporters/pdf.ts +35 -1
- package/apps/studio/git/service.ts +4 -1
- package/apps/studio/http.ts +45 -0
- package/apps/studio/paths.ts +37 -1
- package/apps/studio/server.ts +75 -2
- package/apps/studio/sync/autocommit.ts +299 -0
- package/apps/studio/sync/doc-name.ts +228 -0
- package/apps/studio/sync/index.ts +95 -1
- package/apps/studio/sync/workspace-signin.ts +301 -0
- package/apps/studio/test/acp-plugin-bootstrap.test.ts +34 -0
- package/apps/studio/test/acp-session-plugins.test.ts +6 -0
- package/apps/studio/test/assets-s3.test.ts +249 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +7 -0
- package/apps/studio/test/collab-origin-gate.test.ts +323 -0
- package/apps/studio/test/exporters/pdf.test.ts +33 -1
- package/apps/studio/test/sync-autocommit.test.ts +334 -0
- package/apps/studio/test/sync-doc-name.test.ts +281 -0
- package/apps/studio/test/workspace-containment.test.ts +258 -0
- package/apps/studio/test/workspace-signin.test.ts +270 -0
- package/apps/studio/use-collab.tsx +28 -1
- package/apps/studio/workspace-mode.ts +210 -0
- package/apps/studio/ws.ts +11 -2
- package/cli/bin/maude.mjs +1 -0
- package/cli/commands/hub-workspace.mjs +341 -0
- package/cli/commands/hub.mjs +325 -3
- package/cli/commands/hub.test.mjs +14 -1
- package/cli/commands/init.mjs +80 -3
- package/cli/commands/kg.mjs +368 -0
- package/cli/commands/kg.test.mjs +118 -0
- package/cli/lib/cell-plan.mjs +302 -0
- package/cli/lib/cell-plan.test.mjs +225 -0
- package/cli/lib/ddr-to-kgai.mjs +648 -0
- package/cli/lib/ddr-to-kgai.test.mjs +99 -0
- package/cli/lib/flow-design-integration.test.mjs +2 -2
- package/cli/lib/gitignore-block.mjs +16 -1
- package/cli/lib/plugin-name-namespace.test.mjs +71 -0
- package/cli/lib/workspace-plan.mjs +422 -0
- package/cli/lib/workspace-plan.test.mjs +223 -0
- package/package.json +8 -8
- package/plugins/design/dependencies.json +17 -0
- package/plugins/flow/.claude-plugin/config.schema.json +66 -0
- package/plugins/flow/dependencies.json +17 -0
package/README.md
CHANGED
|
@@ -82,7 +82,7 @@ maude doctor --json # machine-readable envelope
|
|
|
82
82
|
|
|
83
83
|
### Sidecar cache — `maude cache`
|
|
84
84
|
|
|
85
|
-
Flow + design commands reuse expensive cross-session work through a small sidecar cache at `.ai/cache/` ([DDR-061](.ai/decisions/DDR-061-sidecar-cache-monitor-background-orchestration.md)): domain research (skips 30–90 s of WebSearch on a same-domain brief), codebase-intelligence scans (skips rescan when the tree is unchanged), parsed design-system vocabulary, and security-review reuse (one shared 1-hour window across `/flow:validate`, `/flow:done`, `/flow:validate-security`). Correctness comes first — most layers are content-addressed (a changed input changes the key → guaranteed miss), and a stale entry is never served speculatively.
|
|
85
|
+
Flow + design commands reuse expensive cross-session work through a small sidecar cache at `.ai/cache/` ([DDR-061](.ai/archive/decisions/DDR-061-sidecar-cache-monitor-background-orchestration.md)): domain research (skips 30–90 s of WebSearch on a same-domain brief), codebase-intelligence scans (skips rescan when the tree is unchanged), parsed design-system vocabulary, and security-review reuse (one shared 1-hour window across `/flow:validate`, `/flow:done`, `/flow:validate-security`). Correctness comes first — most layers are content-addressed (a changed input changes the key → guaranteed miss), and a stale entry is never served speculatively.
|
|
86
86
|
|
|
87
87
|
```sh
|
|
88
88
|
maude cache list # layers, entry counts, sizes, last-write time
|
|
@@ -95,7 +95,7 @@ The `research/domain` and `codebase-intelligence` layers are **committed** (dete
|
|
|
95
95
|
|
|
96
96
|
### Plugins call `maude` for executable logic
|
|
97
97
|
|
|
98
|
-
Plugin slash-commands reach all executable logic through the on-PATH `maude` binary — never a relative `cli/lib/*.mjs` path or a raw `$CLAUDE_PLUGIN_ROOT/dev-server/bin/*.sh` invocation ([DDR-062](.ai/decisions/DDR-062-plugins-reach-executable-logic-via-maude.md)). The marketplace copies each plugin alone (no sibling `cli/`, no `dev-server/`), and a flow command's `$CLAUDE_PLUGIN_ROOT` points at the flow plugin (which has no dev-server at all) — so the only contract that holds across every install shape is `maude`, which resolves bundled helpers from its own package root. Cache/preflight go via `maude cache …` / `maude preflight …`; the design dev-server's bash helpers go via **`maude design <verb>`** (`screenshot`, `server-up`, `prep`, `slug`, `smoke`, `runtime-health`, …) — see `maude design help`. Keep the global `maude` current; a stale binary means stale helpers.
|
|
98
|
+
Plugin slash-commands reach all executable logic through the on-PATH `maude` binary — never a relative `cli/lib/*.mjs` path or a raw `$CLAUDE_PLUGIN_ROOT/dev-server/bin/*.sh` invocation ([DDR-062](.ai/archive/decisions/DDR-062-plugins-reach-executable-logic-via-maude.md)). The marketplace copies each plugin alone (no sibling `cli/`, no `dev-server/`), and a flow command's `$CLAUDE_PLUGIN_ROOT` points at the flow plugin (which has no dev-server at all) — so the only contract that holds across every install shape is `maude`, which resolves bundled helpers from its own package root. Cache/preflight go via `maude cache …` / `maude preflight …`; the design dev-server's bash helpers go via **`maude design <verb>`** (`screenshot`, `server-up`, `prep`, `slug`, `smoke`, `runtime-health`, …) — see `maude design help`. Keep the global `maude` current; a stale binary means stale helpers.
|
|
99
99
|
|
|
100
100
|
## Runtime requirements
|
|
101
101
|
|
|
@@ -105,10 +105,10 @@ Plugin slash-commands reach all executable logic through the on-PATH `maude` bin
|
|
|
105
105
|
|
|
106
106
|
## Collaboration model
|
|
107
107
|
|
|
108
|
-
Two clean paths, no middle ground ([DDR-047](.ai/decisions/DDR-047-collab-scope-cut-no-lan-mode-hub-admin-ui.md)):
|
|
108
|
+
Two clean paths, no middle ground ([DDR-047](.ai/archive/decisions/DDR-047-collab-scope-cut-no-lan-mode-hub-admin-ui.md)):
|
|
109
109
|
|
|
110
110
|
- **v1.0 — git handoff OR loopback multi-tab.** Push / pull is the cross-machine story. On a single machine, two browser tabs (or two Claude Code instances editing the same repo) sync cursors, comments, and annotations live over loopback WebSocket. The dev server refuses any non-loopback `host` header on the collab WS endpoint.
|
|
111
|
-
- **v1.1 — deploy a hub** (Phase 9, in-flight). Cross-machine live collab needs a hub binary you deploy yourself. No tunnel mode; no shared cloud. **Boot order can't eat your work** ([DDR-102](.ai/decisions/DDR-102-cold-start-divergence-resolution.md)): a per-machine journal tells clean catch-ups apart from genuine divergence; diverged canvases snapshot **both** versions to `_history/` before the newer one wins, so the loser is one `/design:rollback` away — and `maude design status` reports per-canvas sync state honestly (synced / pending / auth-rejected).
|
|
111
|
+
- **v1.1 — deploy a hub** (Phase 9, in-flight). Cross-machine live collab needs a hub binary you deploy yourself. No tunnel mode; no shared cloud. **Boot order can't eat your work** ([DDR-102](.ai/archive/decisions/DDR-102-cold-start-divergence-resolution.md)): a per-machine journal tells clean catch-ups apart from genuine divergence; diverged canvases snapshot **both** versions to `_history/` before the newer one wins, so the loser is one `/design:rollback` away — and `maude design status` reports per-canvas sync state honestly (synced / pending / auth-rejected).
|
|
112
112
|
|
|
113
113
|
## Security
|
|
114
114
|
|
|
@@ -118,7 +118,8 @@ Solo mode (the default) is fully local — no accounts, no telemetry, no network
|
|
|
118
118
|
|
|
119
119
|
User-facing docs live in two places — the README points you the right way:
|
|
120
120
|
|
|
121
|
-
- **Reference** (every command, every config key, recipes for Next.js / Expo / monorepo) → [`site/content/docs/`](./site/content/docs/) (served at https://maude.sh once Vercel is wired — see [DDR-005](.ai/decisions/DDR-005-docs-site-stack-and-hosting.md)).
|
|
121
|
+
- **Reference** (every command, every config key, recipes for Next.js / Expo / monorepo) → [`site/content/docs/`](./site/content/docs/) (served at https://maude.sh once Vercel is wired — see [DDR-005](.ai/archive/decisions/DDR-005-docs-site-stack-and-hosting.md)).
|
|
122
|
+
- **kgai knowledge-graph backend** (opt-in shared decision memory across repos) → [`docs/kgai-onboarding.md`](./docs/kgai-onboarding.md) (per user) + [`docs/kgai-company-setup.md`](./docs/kgai-company-setup.md) (one-time admin). Off by default — absent `kg`, every command runs its classic `.ai/` path.
|
|
122
123
|
- **Quickstart** + **contributor info** → this README.
|
|
123
124
|
|
|
124
125
|
The docs site auto-generates per-command pages from `plugins/{flow,design}/commands/*.md` frontmatter and a typed schema reference from `plugins/flow/.claude-plugin/config.schema.json`. Adding a new command → docs update on next build.
|
|
@@ -130,7 +131,7 @@ The repo is a **pnpm workspace monorepo** with one published npm package (`@1agh
|
|
|
130
131
|
| Workspace | Purpose |
|
|
131
132
|
| --------- | ------- |
|
|
132
133
|
| `.` (root) | The single npm publisher — CLI, dev-server entry, plugin templates that ship to npm. |
|
|
133
|
-
| `site/` | Docs site — Fumadocs + Next.js, deployed to Vercel ([DDR-005](.ai/decisions/DDR-005-docs-site-stack-and-hosting.md)). |
|
|
134
|
+
| `site/` | Docs site — Fumadocs + Next.js, deployed to Vercel ([DDR-005](.ai/archive/decisions/DDR-005-docs-site-stack-and-hosting.md)). |
|
|
134
135
|
| `apps/studio/` | Zero-dep Node dev server + browser client. Bundled output (`dist/`) is the only thing in the npm tarball. |
|
|
135
136
|
| `apps/hub/` | Reserved for the v1.1 federated hub (Phase 9). |
|
|
136
137
|
|
|
@@ -396,7 +396,14 @@ export function newSessionParams(
|
|
|
396
396
|
// gets it double-loaded with zero suppression (the exact double-
|
|
397
397
|
// registration risk this override exists to close). No test currently
|
|
398
398
|
// catches this drift.
|
|
399
|
-
|
|
399
|
+
// `kgai` is the third-party autonomous-capture plugin the desktop bundle
|
|
400
|
+
// injects (plugin-bootstrap.ts). Suppressing its natively-installed copy is
|
|
401
|
+
// MORE load-bearing than for our own plugins: a user-installed kgai would
|
|
402
|
+
// run its own SessionStart `install.sh` (Go/network) and point at a
|
|
403
|
+
// different engine version than the pinned, signed sidecar we ship. Its id
|
|
404
|
+
// has no `@marketplace` suffix — it's injected as a bare local plugin dir
|
|
405
|
+
// whose manifest `name` is `kgai`.
|
|
406
|
+
options.settings = { enabledPlugins: { 'design@maude': false, kgai: false } };
|
|
400
407
|
}
|
|
401
408
|
meta.claudeCode = { options };
|
|
402
409
|
return {
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
// id via the SDK's documented `flag > user` settings precedence — so the
|
|
39
39
|
// bundled copy injected here is the ONLY one that ever loads.
|
|
40
40
|
|
|
41
|
-
import { DESIGN_PLUGIN_DIR, FLOW_PLUGIN_DIR } from '../paths.ts';
|
|
41
|
+
import { DESIGN_PLUGIN_DIR, FLOW_PLUGIN_DIR, KGAI_PLUGIN_DIR } from '../paths.ts';
|
|
42
42
|
|
|
43
43
|
/**
|
|
44
44
|
* SDK plugin-load config (`@anthropic-ai/claude-agent-sdk` `SdkPluginConfig`).
|
|
@@ -61,6 +61,8 @@ export interface SessionPluginDeps {
|
|
|
61
61
|
designDir: string | null;
|
|
62
62
|
/** Bundled `flow` plugin dir, or null (npm/web layout). */
|
|
63
63
|
flowDir: string | null;
|
|
64
|
+
/** Bundled third-party `kgai` plugin dir, or null (only staged in the .app). */
|
|
65
|
+
kgaiDir?: string | null;
|
|
64
66
|
}
|
|
65
67
|
|
|
66
68
|
/**
|
|
@@ -81,6 +83,17 @@ export function computeSessionPlugins(deps: SessionPluginDeps): SdkPluginConfig[
|
|
|
81
83
|
// `/flow` auto-load is intentionally OFF for now (2026-07-03) — the chat ships
|
|
82
84
|
// design-only. `deps.flowDir` stays resolved (harmless) so restoring it is a
|
|
83
85
|
// one-liner: re-add `add(deps.flowDir)`.
|
|
86
|
+
//
|
|
87
|
+
// kgai (third-party, MIT) — injected so its `Stop` hook loads and autonomous
|
|
88
|
+
// decision capture actually fires in the ACP panel. Without this the packaged
|
|
89
|
+
// app captures NOTHING: the session is built with settingSources:['user']
|
|
90
|
+
// (DDR-144) and a terminal-less DDR-177 user never marketplace-installs it.
|
|
91
|
+
// Non-null only in the desktop bundle (sync-kg.mjs stages a pinned release).
|
|
92
|
+
//
|
|
93
|
+
// ⚠ DRIFT TRAP — adding an id here REQUIRES a matching `'<id>': false` entry in
|
|
94
|
+
// bridge.ts's hand-maintained `enabledPlugins` suppression literal, or a user
|
|
95
|
+
// who ALSO has it natively enabled gets it double-loaded (see that comment).
|
|
96
|
+
add(deps.kgaiDir ?? null);
|
|
84
97
|
return out;
|
|
85
98
|
}
|
|
86
99
|
|
|
@@ -117,5 +130,6 @@ export function resolveSessionPlugins(): SdkPluginConfig[] {
|
|
|
117
130
|
native: isNativePluginContext(),
|
|
118
131
|
designDir: DESIGN_PLUGIN_DIR,
|
|
119
132
|
flowDir: FLOW_PLUGIN_DIR,
|
|
133
|
+
kgaiDir: KGAI_PLUGIN_DIR,
|
|
120
134
|
});
|
|
121
135
|
}
|
package/apps/studio/api.ts
CHANGED
|
@@ -5,7 +5,7 @@ import crypto from 'node:crypto';
|
|
|
5
5
|
import type { Dirent } from 'node:fs';
|
|
6
6
|
import { lstat, mkdir, readdir, readFile, rename, rm, stat as statp } from 'node:fs/promises';
|
|
7
7
|
import path from 'node:path';
|
|
8
|
-
|
|
8
|
+
import { createAssetMirror, s3ConfigFromEnv } from './assets-s3.ts';
|
|
9
9
|
import { renderBriefBoard, validateCanvasName } from './canvas-create.ts';
|
|
10
10
|
import {
|
|
11
11
|
type AssembleClip,
|
|
@@ -1646,6 +1646,12 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
1646
1646
|
// user input, but a poisoned designRoot must still not escape).
|
|
1647
1647
|
// Running total of bytes this server instance has actually written (post-dedupe).
|
|
1648
1648
|
let assetBytesWritten = 0;
|
|
1649
|
+
// S3/R2 asset lane (Cloud Phase 3 Task 2). Unconfigured by default — a local
|
|
1650
|
+
// project and a single-box self-hoster both work with no bucket at all.
|
|
1651
|
+
const assetMirror = createAssetMirror(s3ConfigFromEnv());
|
|
1652
|
+
if (assetMirror.configured) {
|
|
1653
|
+
console.log(`[assets] mirroring new assets to ${assetMirror.describe}`);
|
|
1654
|
+
}
|
|
1649
1655
|
|
|
1650
1656
|
/**
|
|
1651
1657
|
* DDR-148 — the streaming write path behind `POST /_api/asset`. Reads the
|
|
@@ -1777,7 +1783,17 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
1777
1783
|
}
|
|
1778
1784
|
await rename(tmpAbs, fileAbs);
|
|
1779
1785
|
assetBytesWritten += total;
|
|
1780
|
-
|
|
1786
|
+
const rel = `assets/${name}`;
|
|
1787
|
+
// S3/R2 lane (Cloud Phase 3) — mirror the bytes so a second machine can
|
|
1788
|
+
// resolve them without the file riding git. Deliberately awaited but
|
|
1789
|
+
// never able to fail the save: the asset is already on disk, the mirror
|
|
1790
|
+
// is the redundant copy, and `maude hub asset-check` reconciles a miss.
|
|
1791
|
+
// Only a genuinely NEW file reaches here, so this is not re-uploading on
|
|
1792
|
+
// every dedupe hit.
|
|
1793
|
+
if (assetMirror.configured) {
|
|
1794
|
+
void assetMirror.push(rel, new Uint8Array(await Bun.file(fileAbs).arrayBuffer()));
|
|
1795
|
+
}
|
|
1796
|
+
return { ok: true, path: rel };
|
|
1781
1797
|
} catch (err) {
|
|
1782
1798
|
await cleanup();
|
|
1783
1799
|
return { ok: false, status: 500, error: err instanceof Error ? err.message : 'write failed' };
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
// S3/R2 asset lane — Cloud Phase 3 Task 2.
|
|
2
|
+
//
|
|
3
|
+
// THE PROBLEM. Binary media currently rides git. A 60 MB video in a design repo
|
|
4
|
+
// is 60 MB in every clone, forever, and git's delta compression does nothing for
|
|
5
|
+
// it. DDR-148's line that "video rides git and hub sync" describes an intent the
|
|
6
|
+
// code never made true cross-machine; this lane is what makes heavy media
|
|
7
|
+
// actually reach a second machine without bloating history.
|
|
8
|
+
//
|
|
9
|
+
// THE SHAPE. Assets stay CONTENT-ADDRESSED exactly as they are today —
|
|
10
|
+
// `assets/<sha8>.<ext>`. That single property is what makes every operation
|
|
11
|
+
// here safe:
|
|
12
|
+
//
|
|
13
|
+
// • push is idempotent — the same bytes produce the same key, so a re-upload
|
|
14
|
+
// is a no-op rather than a duplicate;
|
|
15
|
+
// • there is no invalidation problem — a key's content never changes, so a
|
|
16
|
+
// cached copy is never stale;
|
|
17
|
+
// • pull is verifiable — the bytes must hash back to the key they came from,
|
|
18
|
+
// which is what lets us accept them from a semi-trusted hub (DDR-054).
|
|
19
|
+
//
|
|
20
|
+
// NEVER GARBAGE-COLLECTED. A canvas in git history can reference an asset that
|
|
21
|
+
// no current canvas does, so "unreferenced" never means "unreachable". Bucket
|
|
22
|
+
// lifecycle/expiry rules MUST be off for the `assets/` prefix — an expired
|
|
23
|
+
// object is a permanently broken canvas with no recovery path.
|
|
24
|
+
//
|
|
25
|
+
// NO PRESIGNED URLS IN A CANVAS. The canvas origin's CSP is `img-src 'self'`
|
|
26
|
+
// (DDR-063/DDR-054) and stays that way: media is fetched through the hub's
|
|
27
|
+
// authenticated proxy and served same-origin. A presigned URL would also be a
|
|
28
|
+
// bearer credential embedded in tenant-authored content — exactly the thing the
|
|
29
|
+
// canvas must never hold.
|
|
30
|
+
|
|
31
|
+
import { createHash, createHmac } from 'node:crypto';
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------- SigV4
|
|
34
|
+
|
|
35
|
+
const sha256Hex = (data: string | Uint8Array): string =>
|
|
36
|
+
createHash('sha256').update(data).digest('hex');
|
|
37
|
+
const hmac = (key: string | Buffer, data: string): Buffer =>
|
|
38
|
+
createHmac('sha256', key).update(data).digest();
|
|
39
|
+
|
|
40
|
+
export interface S3Config {
|
|
41
|
+
endpoint: string;
|
|
42
|
+
bucket: string;
|
|
43
|
+
accessKeyId: string;
|
|
44
|
+
secretAccessKey: string;
|
|
45
|
+
region: string;
|
|
46
|
+
sessionToken?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Resolve an S3 target from environment. Returns null when not configured —
|
|
51
|
+
* an unconfigured asset lane is the DEFAULT, not an error: a local project and
|
|
52
|
+
* a self-hoster on a single box both work without a bucket.
|
|
53
|
+
*/
|
|
54
|
+
export function s3ConfigFromEnv(env: NodeJS.ProcessEnv = process.env): S3Config | null {
|
|
55
|
+
const endpoint = env.MAUDE_S3_ENDPOINT;
|
|
56
|
+
const bucket = env.MAUDE_S3_BUCKET;
|
|
57
|
+
const accessKeyId = env.MAUDE_S3_ACCESS_KEY_ID;
|
|
58
|
+
const secretAccessKey = env.MAUDE_S3_SECRET_ACCESS_KEY;
|
|
59
|
+
if (!endpoint || !bucket || !accessKeyId || !secretAccessKey) return null;
|
|
60
|
+
return {
|
|
61
|
+
endpoint: endpoint.replace(/\/+$/, ''),
|
|
62
|
+
bucket,
|
|
63
|
+
accessKeyId,
|
|
64
|
+
secretAccessKey,
|
|
65
|
+
region: env.MAUDE_S3_REGION || 'auto',
|
|
66
|
+
...(env.MAUDE_S3_SESSION_TOKEN ? { sessionToken: env.MAUDE_S3_SESSION_TOKEN } : {}),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function encodeSegment(segment: string): string {
|
|
71
|
+
return encodeURIComponent(segment).replace(
|
|
72
|
+
/[!'()*]/g,
|
|
73
|
+
(c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function encodeKey(key: string): string {
|
|
78
|
+
return key.split('/').map(encodeSegment).join('/');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface SignedRequest {
|
|
82
|
+
url: string;
|
|
83
|
+
headers: Record<string, string>;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* AWS Signature Version 4 for one request.
|
|
88
|
+
*
|
|
89
|
+
* Hand-rolled for the same reason the hub's is (DDR-194 §7): `@aws-sdk/client-s3`
|
|
90
|
+
* is ~20 MB and several hundred packages to perform three HTTP verbs, and this
|
|
91
|
+
* code ships inside a compiled binary users install. Deliberately mirrors
|
|
92
|
+
* `apps/hub/src/s3.mjs` rather than sharing it — the two packages build
|
|
93
|
+
* independently — and `test/assets-s3.test.ts` pins them to the same signature
|
|
94
|
+
* for the same input so they cannot drift.
|
|
95
|
+
*/
|
|
96
|
+
export function signRequest(
|
|
97
|
+
cfg: S3Config,
|
|
98
|
+
{
|
|
99
|
+
method,
|
|
100
|
+
key,
|
|
101
|
+
body = null,
|
|
102
|
+
now = new Date(),
|
|
103
|
+
}: { method: string; key: string; body?: Uint8Array | null; now?: Date }
|
|
104
|
+
): SignedRequest {
|
|
105
|
+
const url = new URL(`${cfg.endpoint}/${cfg.bucket}${key ? `/${encodeKey(key)}` : ''}`);
|
|
106
|
+
const iso = now
|
|
107
|
+
.toISOString()
|
|
108
|
+
.replace(/[-:]/g, '')
|
|
109
|
+
.replace(/\.\d{3}/, '');
|
|
110
|
+
const dateStamp = iso.slice(0, 8);
|
|
111
|
+
const payloadHash = body === null ? sha256Hex('') : sha256Hex(body);
|
|
112
|
+
|
|
113
|
+
const headers: Record<string, string> = {
|
|
114
|
+
host: url.host,
|
|
115
|
+
'x-amz-content-sha256': payloadHash,
|
|
116
|
+
'x-amz-date': iso,
|
|
117
|
+
...(cfg.sessionToken ? { 'x-amz-security-token': cfg.sessionToken } : {}),
|
|
118
|
+
};
|
|
119
|
+
const names = Object.keys(headers).sort();
|
|
120
|
+
const canonicalHeaders = names.map((h) => `${h}:${headers[h]}\n`).join('');
|
|
121
|
+
const signedHeaders = names.join(';');
|
|
122
|
+
const canonicalRequest = [
|
|
123
|
+
method,
|
|
124
|
+
url.pathname,
|
|
125
|
+
'',
|
|
126
|
+
canonicalHeaders,
|
|
127
|
+
signedHeaders,
|
|
128
|
+
payloadHash,
|
|
129
|
+
].join('\n');
|
|
130
|
+
|
|
131
|
+
const scope = `${dateStamp}/${cfg.region}/s3/aws4_request`;
|
|
132
|
+
const stringToSign = ['AWS4-HMAC-SHA256', iso, scope, sha256Hex(canonicalRequest)].join('\n');
|
|
133
|
+
|
|
134
|
+
let signingKey = hmac(`AWS4${cfg.secretAccessKey}`, dateStamp);
|
|
135
|
+
signingKey = hmac(signingKey, cfg.region);
|
|
136
|
+
signingKey = hmac(signingKey, 's3');
|
|
137
|
+
signingKey = hmac(signingKey, 'aws4_request');
|
|
138
|
+
const signature = createHmac('sha256', signingKey).update(stringToSign).digest('hex');
|
|
139
|
+
|
|
140
|
+
headers.authorization =
|
|
141
|
+
`AWS4-HMAC-SHA256 Credential=${cfg.accessKeyId}/${scope}, ` +
|
|
142
|
+
`SignedHeaders=${signedHeaders}, Signature=${signature}`;
|
|
143
|
+
|
|
144
|
+
return { url: url.toString(), headers };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ------------------------------------------------------------ content address
|
|
148
|
+
|
|
149
|
+
/** The `sha8` in `assets/<sha8>.<ext>` — first 8 hex chars of sha256. */
|
|
150
|
+
export function sha8(bytes: Uint8Array): string {
|
|
151
|
+
return createHash('sha256').update(bytes).digest('hex').slice(0, 8);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Extract the sha8 from a designRoot-relative asset path, or null when the path
|
|
156
|
+
* is not a content-addressed asset (a legacy or hand-placed file).
|
|
157
|
+
*
|
|
158
|
+
* The suffix after the hash is deliberately permissive, because the real corpus
|
|
159
|
+
* is more varied than `<sha8>.<ext>`:
|
|
160
|
+
*
|
|
161
|
+
* assets/deadbeef.png saveAsset's own output
|
|
162
|
+
* assets/deadbeef.photo.json a sidecar (dotted, multi-part)
|
|
163
|
+
* assets/deadbeef-cloud.mp4 ingested footage, hash + human label
|
|
164
|
+
*
|
|
165
|
+
* A stricter pattern silently classified the last two as "not content
|
|
166
|
+
* addressed", which made `verifyAssetBytes` refuse them — so the mirror would
|
|
167
|
+
* have rejected legitimate assets as unverifiable. Found by running
|
|
168
|
+
* `maude hub asset-check` against this repo's own design root, not by a test:
|
|
169
|
+
* the fixtures all had the tidy shape.
|
|
170
|
+
*
|
|
171
|
+
* What must NOT match is anything where the 8 hex chars are not a complete
|
|
172
|
+
* token — `deadbeef1.png` is a different name, not this asset.
|
|
173
|
+
*/
|
|
174
|
+
export function sha8FromAssetPath(rel: string): string | null {
|
|
175
|
+
const m = rel.match(/^assets\/([0-9a-f]{8})(?:[-.][^/]*)?$/);
|
|
176
|
+
return m?.[1] ?? null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Verify bytes against the sha8 embedded in the path they were fetched under.
|
|
181
|
+
*
|
|
182
|
+
* This is what makes accepting an asset from a SEMI-TRUSTED hub (DDR-054) safe:
|
|
183
|
+
* the hub can refuse to serve, but it cannot substitute different bytes without
|
|
184
|
+
* the mismatch being detectable. A path with no sha8 (legacy) can't be verified
|
|
185
|
+
* and is treated as unverifiable rather than valid.
|
|
186
|
+
*/
|
|
187
|
+
export function verifyAssetBytes(rel: string, bytes: Uint8Array): boolean {
|
|
188
|
+
const expected = sha8FromAssetPath(rel);
|
|
189
|
+
if (!expected) return false;
|
|
190
|
+
return sha8(bytes) === expected;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ------------------------------------------------------------------ the lane
|
|
194
|
+
|
|
195
|
+
export interface AssetMirror {
|
|
196
|
+
readonly configured: boolean;
|
|
197
|
+
readonly describe: string;
|
|
198
|
+
/**
|
|
199
|
+
* Upload one asset. Content-addressed ⇒ idempotent, so a re-push of identical
|
|
200
|
+
* bytes is harmless. Returns false on failure rather than throwing: a failed
|
|
201
|
+
* mirror must NEVER fail the local save (the file is on disk and in git; the
|
|
202
|
+
* bucket is the redundant copy, not the authority).
|
|
203
|
+
*/
|
|
204
|
+
push(rel: string, bytes: Uint8Array): Promise<boolean>;
|
|
205
|
+
/** Download one asset, or null when absent. Verifies the content address. */
|
|
206
|
+
pull(rel: string): Promise<Uint8Array | null>;
|
|
207
|
+
/** True when the object exists. Used by the dangling-pointer check. */
|
|
208
|
+
has(rel: string): Promise<boolean>;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const NOOP_MIRROR: AssetMirror = {
|
|
212
|
+
configured: false,
|
|
213
|
+
describe: 'none',
|
|
214
|
+
async push() {
|
|
215
|
+
return false;
|
|
216
|
+
},
|
|
217
|
+
async pull() {
|
|
218
|
+
return null;
|
|
219
|
+
},
|
|
220
|
+
async has() {
|
|
221
|
+
return false;
|
|
222
|
+
},
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
export function createAssetMirror(
|
|
226
|
+
cfg: S3Config | null,
|
|
227
|
+
{ log = console }: { log?: Pick<Console, 'warn'> } = {}
|
|
228
|
+
): AssetMirror {
|
|
229
|
+
if (!cfg) return NOOP_MIRROR;
|
|
230
|
+
|
|
231
|
+
const send = (method: string, key: string, body: Uint8Array | null = null) => {
|
|
232
|
+
const { url, headers } = signRequest(cfg, { method, key, body });
|
|
233
|
+
return fetch(url, { method, headers, ...(body === null ? {} : { body }) });
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
configured: true,
|
|
238
|
+
describe: `s3://${cfg.bucket} @ ${cfg.endpoint}`,
|
|
239
|
+
|
|
240
|
+
async push(rel, bytes) {
|
|
241
|
+
try {
|
|
242
|
+
const res = await send('PUT', rel, bytes);
|
|
243
|
+
if (!res.ok) {
|
|
244
|
+
log.warn(`[assets] mirror PUT ${rel} failed: ${res.status}`);
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
247
|
+
return true;
|
|
248
|
+
} catch (err) {
|
|
249
|
+
// Offline, DNS, a bad key — all the same answer. The asset is safely on
|
|
250
|
+
// local disk; a later push (or `maude hub asset-check`) reconciles.
|
|
251
|
+
log.warn(`[assets] mirror PUT ${rel} failed: ${(err as Error).message}`);
|
|
252
|
+
return false;
|
|
253
|
+
}
|
|
254
|
+
},
|
|
255
|
+
|
|
256
|
+
async pull(rel) {
|
|
257
|
+
try {
|
|
258
|
+
const res = await send('GET', rel);
|
|
259
|
+
if (res.status === 404) return null;
|
|
260
|
+
if (!res.ok) {
|
|
261
|
+
log.warn(`[assets] mirror GET ${rel} failed: ${res.status}`);
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
const bytes = new Uint8Array(await res.arrayBuffer());
|
|
265
|
+
if (!verifyAssetBytes(rel, bytes)) {
|
|
266
|
+
// Content address mismatch: either corruption or substitution. Refuse
|
|
267
|
+
// either way — writing these bytes to disk under this name would
|
|
268
|
+
// poison every peer that later mirrors from us.
|
|
269
|
+
log.warn(
|
|
270
|
+
`[assets] REFUSING ${rel}: content does not hash to its own name ` +
|
|
271
|
+
'(corruption or substitution — see DDR-054)'
|
|
272
|
+
);
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
return bytes;
|
|
276
|
+
} catch (err) {
|
|
277
|
+
log.warn(`[assets] mirror GET ${rel} failed: ${(err as Error).message}`);
|
|
278
|
+
return null;
|
|
279
|
+
}
|
|
280
|
+
},
|
|
281
|
+
|
|
282
|
+
async has(rel) {
|
|
283
|
+
try {
|
|
284
|
+
const res = await send('HEAD', rel);
|
|
285
|
+
return res.ok;
|
|
286
|
+
} catch {
|
|
287
|
+
return false;
|
|
288
|
+
}
|
|
289
|
+
},
|
|
290
|
+
};
|
|
291
|
+
}
|
package/apps/studio/build.ts
CHANGED
|
@@ -317,7 +317,7 @@ export function writeCompileEntry(target: PlatformTarget): string {
|
|
|
317
317
|
const bindingSpec = `@oxc-parser/binding-${oxcSlug}/parser.${oxcSlug}.node`;
|
|
318
318
|
const initContent = `// AUTO-GENERATED by build.ts — do not edit by hand.
|
|
319
319
|
// Per-target oxc-parser binding embed (Bun 1.3.4+ --compile NAPI regression
|
|
320
|
-
// workaround — see .ai/decisions/DDR-042-oxc-parser-bun-compile-workaround.md).
|
|
320
|
+
// workaround — see .ai/archive/decisions/DDR-042-oxc-parser-bun-compile-workaround.md).
|
|
321
321
|
// Side-effect module: must be imported BEFORE any oxc-parser usage.
|
|
322
322
|
import bindingPath from ${JSON.stringify(bindingSpec)} with { type: 'file' };
|
|
323
323
|
process.env.NAPI_RS_NATIVE_LIBRARY_PATH = bindingPath;
|
|
@@ -56,12 +56,13 @@ async function autoDownloadBlob(id, fallbackName) {
|
|
|
56
56
|
URL.revokeObjectURL(url);
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
59
|
+
// Native "Save…" — unlike the web `download()` path, this does NOT fetch the
|
|
60
|
+
// job's bytes into the renderer first. `save_export` fetches them itself
|
|
61
|
+
// (RCA issue-desktop-print-pdf-save-as-hang-large-payload: routing a
|
|
62
|
+
// hundreds-of-MB export through a JS Uint8Array + Tauri's JSON IPC froze the
|
|
63
|
+
// app), so all this needs is the filename already carried on the job record.
|
|
64
|
+
async function saveNative(id, filename) {
|
|
65
|
+
return saveExport(filename || 'export', id);
|
|
65
66
|
}
|
|
66
67
|
|
|
67
68
|
export function useExportCenter() {
|
|
@@ -93,11 +93,18 @@ export const pickDirectory = () => invoke('pick_directory');
|
|
|
93
93
|
export const openLocalProject = (path) => invoke('open_local_project', { path });
|
|
94
94
|
/**
|
|
95
95
|
* Native "Save As…" for an export — opens an OS save dialog seeded with
|
|
96
|
-
* `filename`,
|
|
97
|
-
*
|
|
98
|
-
*
|
|
96
|
+
* `filename`, then has the Rust side fetch the finished `jobId`'s bytes
|
|
97
|
+
* directly from the local dev-server and stream them to the chosen path, and
|
|
98
|
+
* resolves to that path (or null if cancelled). Only callable in the native
|
|
99
|
+
* app; the browser build uses the `<a download>` blob instead.
|
|
100
|
+
*
|
|
101
|
+
* Deliberately does NOT take the bytes as an argument (it used to — RCA
|
|
102
|
+
* issue-desktop-print-pdf-save-as-hang-large-payload): shipping a large export
|
|
103
|
+
* (a print-ready PDF can be hundreds of MB) through Tauri's JSON-serialized
|
|
104
|
+
* IPC as `Array.from(new Uint8Array(...))` froze the renderer. Rust fetches
|
|
105
|
+
* the bytes itself now; the webview never sees the payload.
|
|
99
106
|
*/
|
|
100
|
-
export const saveExport = (filename,
|
|
107
|
+
export const saveExport = (filename, jobId) => invoke('save_export', { filename, jobId });
|
|
101
108
|
|
|
102
109
|
/**
|
|
103
110
|
* Native "open file" for media upload — the read counterpart to saveExport.
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// Origin gate for canvas-realm Y.Doc ops — the named-but-undone DDR-122
|
|
2
|
+
// follow-up ("Origin-gate canvas-injected doc ops"), landed by Cloud Phase 1
|
|
3
|
+
// Task 3.
|
|
4
|
+
//
|
|
5
|
+
// THE THREAT (DDR-054 F1/F3, restated by DDR-122's security residual): the
|
|
6
|
+
// canvas iframe is untrusted, and same-realm canvas script can reach the live
|
|
7
|
+
// collab Y.Doc via `useCollab().doc`. Nothing stopped it from writing the BODY
|
|
8
|
+
// lanes — `html` / `css` / `meta` / `syncMeta`, i.e. the canvas's own `.tsx`,
|
|
9
|
+
// its sibling `.css`, and the shared `.meta.json` — which the sync agent then
|
|
10
|
+
// materializes to **every peer's disk**. That is "untrusted canvas writes
|
|
11
|
+
// source code to all peers" (stored-XSS + Claude-Code-context-poisoning).
|
|
12
|
+
//
|
|
13
|
+
// THE GATE, dual-lock (DDR-063's canvas-origin split is the seam that makes
|
|
14
|
+
// lock 2 expressible at all):
|
|
15
|
+
//
|
|
16
|
+
// Lock 1 — CLIENT (`use-collab.tsx`): a local doc update that touches a body
|
|
17
|
+
// lane is not broadcast to the server unless its transaction origin
|
|
18
|
+
// is a *trusted sentinel* registered here. Closes the honest path
|
|
19
|
+
// (`useCollab().doc.getText('html').insert(...)`).
|
|
20
|
+
// Lock 2 — SERVER (`collab/room.ts`): an update arriving on a collab socket
|
|
21
|
+
// that was upgraded on the CANVAS origin is validated against a
|
|
22
|
+
// mirror doc before it is allowed to touch the real room doc; a
|
|
23
|
+
// body-lane write is refused outright and never broadcast. Closes
|
|
24
|
+
// the bypass where canvas script skips `use-collab` and opens its
|
|
25
|
+
// own WebSocket to `/_ws/collab/:slug`.
|
|
26
|
+
//
|
|
27
|
+
// Lock 1 alone is defense-in-depth; lock 2 is the actual boundary. Both exist
|
|
28
|
+
// because lock 1 is where the intent is legible and lock 2 is where it is
|
|
29
|
+
// enforceable.
|
|
30
|
+
//
|
|
31
|
+
// WHY A MIRROR DOC AND NOT UPDATE INSPECTION (load-bearing, don't "simplify"):
|
|
32
|
+
// `Y.decodeUpdate` only reveals a struct's parent when the struct has neither a
|
|
33
|
+
// left nor a right origin (yjs writes the parent key *only* in that case). An
|
|
34
|
+
// insert into existing text carries an origin ID instead, so the lane it targets
|
|
35
|
+
// is simply not in the bytes — it is only resolvable against a doc that already
|
|
36
|
+
// holds the referenced items. Hence: apply to a mirror of the room doc, read
|
|
37
|
+
// `transaction.changed`, decide, and only then touch the real doc.
|
|
38
|
+
|
|
39
|
+
import * as Y from 'yjs';
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Lanes that carry the canvas SOURCE (and its sync bookkeeping). Written only
|
|
43
|
+
* by server-side code — `sync/agent.ts`, `sync/codec.ts`, `sync/projection.ts`.
|
|
44
|
+
* No browser-realm code writes these today; the gate keeps it that way.
|
|
45
|
+
*/
|
|
46
|
+
const BODY_LANES = new Set<string>(['html', 'css', 'meta', 'syncMeta']);
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Lanes the canvas realm legitimately co-authors — annotations (draw layer),
|
|
50
|
+
* comments, and the presentation channel. Deliberately NOT used as an allowlist
|
|
51
|
+
* for the gate (a new lane must be reasoned about, not silently permitted); it
|
|
52
|
+
* documents intent and backs `isCanvasAuthorableLane` for callers that want the
|
|
53
|
+
* positive form.
|
|
54
|
+
*/
|
|
55
|
+
const CANVAS_AUTHORABLE_LANES = new Set<string>(['comments', 'annotations', 'presentation']);
|
|
56
|
+
|
|
57
|
+
/** True when `name` is a source-carrying lane the canvas realm may never write. */
|
|
58
|
+
export function isBodyLane(name: string): boolean {
|
|
59
|
+
return BODY_LANES.has(name);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** True when `name` is a lane the canvas realm co-authors by design. */
|
|
63
|
+
export function isCanvasAuthorableLane(name: string): boolean {
|
|
64
|
+
return CANVAS_AUTHORABLE_LANES.has(name);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Test/introspection — the frozen lane vocabulary as plain arrays. */
|
|
68
|
+
export function laneVocabulary(): { body: string[]; canvasAuthorable: string[] } {
|
|
69
|
+
return { body: [...BODY_LANES].sort(), canvasAuthorable: [...CANVAS_AUTHORABLE_LANES].sort() };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// Trusted origin sentinels
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
// A WeakSet, not a marker property: a marker property is forgeable by any
|
|
77
|
+
// same-realm script (`{ maudeTrusted: true }`), a WeakSet membership is not —
|
|
78
|
+
// an attacker would need the sentinel *object itself*, which is only reachable
|
|
79
|
+
// from module scope of the modules that legitimately hold it.
|
|
80
|
+
const TRUSTED_ORIGINS = new WeakSet<object>();
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Register `origin` as a trusted doc-update origin. Returns it, so a sentinel
|
|
84
|
+
* can be declared and marked in one expression. Frozen objects are fine —
|
|
85
|
+
* freezing does not affect WeakSet membership.
|
|
86
|
+
*/
|
|
87
|
+
export function markTrustedOrigin<T extends object>(origin: T): T {
|
|
88
|
+
TRUSTED_ORIGINS.add(origin);
|
|
89
|
+
return origin;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** True when a Y transaction origin was registered via `markTrustedOrigin`. */
|
|
93
|
+
export function isTrustedOrigin(origin: unknown): boolean {
|
|
94
|
+
return typeof origin === 'object' && origin !== null && TRUSTED_ORIGINS.has(origin as object);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The shell's sanctioned edit path — the inspector / shell UI mutating a body
|
|
99
|
+
* lane on behalf of an explicit user gesture. Nothing in the canvas realm can
|
|
100
|
+
* obtain this object; it is exported for the shell modules that need it.
|
|
101
|
+
*/
|
|
102
|
+
export const SHELL_EDIT_ORIGIN: object = markTrustedOrigin(
|
|
103
|
+
Object.freeze({ maudeOrigin: 'shell-edit' })
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The local sync agent applying a disk-authoritative body into the doc. Used by
|
|
108
|
+
* server-side code that runs outside the canvas realm entirely; marked here so
|
|
109
|
+
* the client-side lock stays correct if a future build ever colocates them.
|
|
110
|
+
*/
|
|
111
|
+
export const SYNC_AGENT_ORIGIN: object = markTrustedOrigin(
|
|
112
|
+
Object.freeze({ maudeOrigin: 'sync-agent' })
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
// Transaction inspection
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Root shared-type names a transaction touched.
|
|
121
|
+
*
|
|
122
|
+
* `transaction.changed` is keyed by the *concrete* type that changed, which for
|
|
123
|
+
* a nested structure is not the root — so walk `_item.parent` up to the root and
|
|
124
|
+
* resolve its registered name via `Y.findRootTypeKey`. A type that cannot be
|
|
125
|
+
* resolved (detached / mid-destroy) yields the sentinel `'<unresolved>'`, which
|
|
126
|
+
* is deliberately NOT a body lane but also not an authorable one — callers that
|
|
127
|
+
* fail closed should treat an unresolved root as untrusted.
|
|
128
|
+
*/
|
|
129
|
+
export function rootTypesTouched(transaction: Y.Transaction): Set<string> {
|
|
130
|
+
const roots = new Set<string>();
|
|
131
|
+
for (const type of transaction.changed.keys()) {
|
|
132
|
+
let t: Y.AbstractType<unknown> = type;
|
|
133
|
+
// biome-ignore lint/suspicious/noExplicitAny: `_item` is yjs-internal but stable API surface for parent walks.
|
|
134
|
+
while ((t as any)._item != null) t = (t as any)._item.parent;
|
|
135
|
+
try {
|
|
136
|
+
roots.add(Y.findRootTypeKey(t));
|
|
137
|
+
} catch {
|
|
138
|
+
roots.add('<unresolved>');
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return roots;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** True when a transaction wrote any source-carrying lane. */
|
|
145
|
+
export function touchesBodyLane(transaction: Y.Transaction): boolean {
|
|
146
|
+
for (const name of rootTypesTouched(transaction)) {
|
|
147
|
+
if (isBodyLane(name)) return true;
|
|
148
|
+
}
|
|
149
|
+
return false;
|
|
150
|
+
}
|