@1agh/maude 0.54.0 → 0.56.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/apps/studio/annotations-layer.tsx +11 -1
- package/apps/studio/bin/_smart-frames.mjs +187 -20
- package/apps/studio/bin/_smart-frames.test.mjs +59 -4
- package/apps/studio/bin/_transcribe.mjs +40 -3
- package/apps/studio/bin/smoke.sh +7 -1
- package/apps/studio/canvas-build-sandbox.ts +101 -9
- package/apps/studio/canvas-build-worker.ts +7 -1
- package/apps/studio/canvas-build.ts +9 -1
- package/apps/studio/client/app.jsx +49 -3
- package/apps/studio/client/github.js +7 -0
- package/apps/studio/client/panels/CloudBar.jsx +505 -45
- package/apps/studio/client/panels/GitPanel.jsx +38 -25
- package/apps/studio/client/panels/SettingsPanel.jsx +239 -27
- package/apps/studio/client/styles/3-shell-maude.css +30 -0
- package/apps/studio/client/styles/4-components.css +72 -0
- package/apps/studio/cloud/endpoints.ts +104 -9
- package/apps/studio/collab/persistence.ts +29 -2
- package/apps/studio/config.schema.json +3 -3
- package/apps/studio/context.ts +41 -0
- package/apps/studio/dist/client.bundle.js +1545 -1545
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/generation/gemma-models.ts +312 -14
- package/apps/studio/generation/prefs.ts +7 -2
- package/apps/studio/generation/runtime-probe.ts +50 -0
- package/apps/studio/generation/whisper-models.ts +124 -0
- package/apps/studio/hmr-broadcast.ts +67 -0
- package/apps/studio/http.ts +210 -110
- package/apps/studio/input-router.tsx +55 -2
- package/apps/studio/server.ts +11 -9
- package/apps/studio/sync/autocommit.ts +61 -2
- package/apps/studio/sync/cell-pairing.ts +174 -0
- package/apps/studio/sync/codec.ts +11 -5
- package/apps/studio/sync/index.ts +239 -26
- package/apps/studio/sync/limits.ts +49 -0
- package/apps/studio/sync/loopback.ts +21 -0
- package/apps/studio/sync/projection.ts +47 -12
- package/apps/studio/sync/supervisor.ts +178 -0
- package/apps/studio/test/cloud-endpoints.test.ts +326 -4
- package/apps/studio/test/csrf-write-guard.test.ts +19 -2
- package/apps/studio/test/gemma-models.test.ts +245 -0
- package/apps/studio/test/hmr-broadcast.test.ts +57 -1
- package/apps/studio/test/input-router.test.ts +95 -0
- package/apps/studio/test/shared-doc-cell-pairing.test.ts +639 -0
- package/apps/studio/test/sync-autocommit.test.ts +47 -0
- package/apps/studio/test/sync-supervisor.test.ts +212 -0
- package/apps/studio/test/trusted-request-host.test.ts +66 -0
- package/apps/studio/test/whisper-setup.test.ts +97 -0
- package/apps/studio/whats-new.json +45 -0
- package/apps/studio/ws.ts +9 -1
- package/cli/commands/kg.mjs +9 -2
- package/package.json +8 -8
- package/plugins/design/dependencies.json +21 -3
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// Sync supervisor — one owner of the live sync runtime, so linking a project
|
|
2
|
+
// can START SYNCING instead of asking a person to restart the app.
|
|
3
|
+
//
|
|
4
|
+
// Why this exists: `createSyncRuntime` captures `cfg.linkedHub` once, and
|
|
5
|
+
// `reloadConfig` deliberately refuses to hot-swap it (a config.json is
|
|
6
|
+
// committed and travels with the repo — a poisoned one must never re-point the
|
|
7
|
+
// sync socket, DDR-149). That rule is about the FILE WATCHER, and it was being
|
|
8
|
+
// paid for by the person: after pressing Connect in the cloud panel the whole
|
|
9
|
+
// product did nothing visible and the UI said "restart the studio server to
|
|
10
|
+
// start syncing" — a sentence with no button behind it, and no meaning at all
|
|
11
|
+
// inside the desktop app, where there is no server the person can see.
|
|
12
|
+
//
|
|
13
|
+
// The supervisor separates the two paths that were conflated:
|
|
14
|
+
//
|
|
15
|
+
// • a linkedHub arriving from DISK → still boot-pinned, still refused;
|
|
16
|
+
// • a linkedHub the person JUST AUTHORIZED in trusted app chrome, whose hub
|
|
17
|
+
// credential this process itself stored one line earlier → adopted in
|
|
18
|
+
// memory and applied by a real stop() + start() cycle.
|
|
19
|
+
//
|
|
20
|
+
// The second path never reads the value back off disk: `restart()` takes the
|
|
21
|
+
// LinkedHub object the attach lane just wrote, so nothing that happens to
|
|
22
|
+
// `.design/config.json` in between can steer the socket.
|
|
23
|
+
|
|
24
|
+
import { adoptLinkedHub, type Context, type LinkedHub } from '../context.ts';
|
|
25
|
+
import { resolveCellPairing } from './cell-pairing.ts';
|
|
26
|
+
import { getHubToken } from './hubs-config.ts';
|
|
27
|
+
import {
|
|
28
|
+
type CreateSyncRuntimeOptions,
|
|
29
|
+
checkUrlScheme,
|
|
30
|
+
createSyncRuntime,
|
|
31
|
+
type SyncRuntime,
|
|
32
|
+
} from './index.ts';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* What happened when the runtime was (re)started — the honest answer the cloud
|
|
36
|
+
* panel repeats back to the person. `syncing:false` is never silent: `reason`
|
|
37
|
+
* is what to say, and the caller renders it verbatim rather than guessing.
|
|
38
|
+
*/
|
|
39
|
+
export interface SyncStartOutcome {
|
|
40
|
+
syncing: boolean;
|
|
41
|
+
/** Canvases the runtime actually took ownership of. */
|
|
42
|
+
canvases: number;
|
|
43
|
+
/** Machine-readable cause when `syncing` is false. */
|
|
44
|
+
reason?: 'unlinked' | 'no-credential' | 'insecure-url' | 'refused' | 'nothing-syncable' | 'error';
|
|
45
|
+
/** One sentence a person can act on. Present whenever `syncing` is false. */
|
|
46
|
+
detail?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface SyncSupervisor {
|
|
50
|
+
/** Boot the runtime from the config as loaded. Safe when unlinked. */
|
|
51
|
+
start(): Promise<SyncStartOutcome>;
|
|
52
|
+
/**
|
|
53
|
+
* Adopt a just-authorized link and cycle the runtime. `linkedHub` is passed
|
|
54
|
+
* BY VALUE from the attach lane on purpose — never re-read from disk.
|
|
55
|
+
*/
|
|
56
|
+
restart(linkedHub?: LinkedHub): Promise<SyncStartOutcome>;
|
|
57
|
+
stop(): Promise<void>;
|
|
58
|
+
/** The live runtime, or null in solo mode. Test/inspection surface. */
|
|
59
|
+
current(): SyncRuntime | null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Why did `createSyncRuntime` decline? It logs its refusals and returns null,
|
|
64
|
+
* which is right for a boot path and useless for a dialog. Re-derive the cause
|
|
65
|
+
* from the same inputs so the panel can say something true. Kept in this order
|
|
66
|
+
* deliberately — it mirrors the guard order in createSyncRuntime.
|
|
67
|
+
*/
|
|
68
|
+
function diagnose(ctx: Context): { reason: SyncStartOutcome['reason']; detail: string } {
|
|
69
|
+
// Cell pairing supplies its own hub URL + credential from the environment, so
|
|
70
|
+
// "no linkedHub on disk" is not the diagnosis there — reporting "not linked to
|
|
71
|
+
// a workspace" for a cell that IS paired would send an operator looking for a
|
|
72
|
+
// config field that has nothing to do with it. Both pairing outcomes (refused
|
|
73
|
+
// to resolve, or resolved but the runtime still declined) return here — a
|
|
74
|
+
// successfully-resolved pairing always carries a non-empty `url`
|
|
75
|
+
// (`resolveCellPairing` refuses an empty one), so there is no "unlinked"
|
|
76
|
+
// state to fall through to on this path, unlike the plain linkedHub case below.
|
|
77
|
+
const { pairing, detail: pairingRefusal } = resolveCellPairing();
|
|
78
|
+
if (pairingRefusal) {
|
|
79
|
+
return { reason: 'refused', detail: pairingRefusal };
|
|
80
|
+
}
|
|
81
|
+
if (pairing) {
|
|
82
|
+
return {
|
|
83
|
+
reason: 'refused',
|
|
84
|
+
detail: 'Cell pairing is on but syncing did not start — see the server log for why.',
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
const linked = ctx.cfg.linkedHub;
|
|
88
|
+
if (!linked) {
|
|
89
|
+
return { reason: 'unlinked', detail: 'This project is not linked to a workspace.' };
|
|
90
|
+
}
|
|
91
|
+
const scheme = checkUrlScheme(linked.url);
|
|
92
|
+
if (scheme) {
|
|
93
|
+
return { reason: 'insecure-url', detail: scheme };
|
|
94
|
+
}
|
|
95
|
+
if (!getHubToken(linked.url)) {
|
|
96
|
+
return {
|
|
97
|
+
reason: 'no-credential',
|
|
98
|
+
detail: 'No sign-in for this workspace is stored on this machine yet.',
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
return {
|
|
102
|
+
reason: 'refused',
|
|
103
|
+
detail: 'Syncing is switched off in this environment — see the server log for why.',
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function createSyncSupervisor(
|
|
108
|
+
ctx: Context,
|
|
109
|
+
opts: CreateSyncRuntimeOptions = {},
|
|
110
|
+
factory: (c: Context, o: CreateSyncRuntimeOptions) => SyncRuntime | null = createSyncRuntime
|
|
111
|
+
): SyncSupervisor {
|
|
112
|
+
let runtime: SyncRuntime | null = null;
|
|
113
|
+
// Two Connects pressed in quick succession must not interleave a stop() with
|
|
114
|
+
// another cycle's start() — the second would attach providers the first is
|
|
115
|
+
// still tearing down. One chain, in order.
|
|
116
|
+
let chain: Promise<unknown> = Promise.resolve();
|
|
117
|
+
|
|
118
|
+
async function boot(): Promise<SyncStartOutcome> {
|
|
119
|
+
runtime = factory(ctx, opts);
|
|
120
|
+
if (!runtime) return { syncing: false, canvases: 0, ...diagnose(ctx) };
|
|
121
|
+
try {
|
|
122
|
+
await runtime.start();
|
|
123
|
+
} catch (err) {
|
|
124
|
+
// Same posture as the boot path: a failed sync start never takes the
|
|
125
|
+
// server (or the attach response) down with it — solo mode continues.
|
|
126
|
+
console.error('[sync] start failed — continuing in solo mode:', err);
|
|
127
|
+
return {
|
|
128
|
+
syncing: false,
|
|
129
|
+
canvases: 0,
|
|
130
|
+
reason: 'error',
|
|
131
|
+
detail: `Syncing could not start: ${(err as Error).message}`,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
const canvases = runtime.size();
|
|
135
|
+
if (canvases === 0) {
|
|
136
|
+
// Linked and authenticated, but nothing on disk qualifies. The runtime
|
|
137
|
+
// already wrote the full reason into `_sync.json` (9.1-D); reuse it
|
|
138
|
+
// rather than inventing a second explanation that can drift from it.
|
|
139
|
+
const status = runtime.status() as { reason?: string } | null;
|
|
140
|
+
return {
|
|
141
|
+
syncing: false,
|
|
142
|
+
canvases: 0,
|
|
143
|
+
reason: 'nothing-syncable',
|
|
144
|
+
detail: status?.reason ?? 'No canvases in this project are syncable yet.',
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
return { syncing: true, canvases };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function serialize<T>(work: () => Promise<T>): Promise<T> {
|
|
151
|
+
const next = chain.then(work, work);
|
|
152
|
+
// Keep the chain alive even when a link fails — the NEXT attach must still
|
|
153
|
+
// be able to run. (A rejected chain would poison every later restart.)
|
|
154
|
+
chain = next.catch(() => {});
|
|
155
|
+
return next;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
start: () => serialize(boot),
|
|
160
|
+
restart: (linkedHub) =>
|
|
161
|
+
serialize(async () => {
|
|
162
|
+
try {
|
|
163
|
+
await runtime?.stop();
|
|
164
|
+
} catch (err) {
|
|
165
|
+
console.error('[sync] stop before restart failed:', err);
|
|
166
|
+
}
|
|
167
|
+
runtime = null;
|
|
168
|
+
if (linkedHub) adoptLinkedHub(ctx, linkedHub);
|
|
169
|
+
return boot();
|
|
170
|
+
}),
|
|
171
|
+
stop: () =>
|
|
172
|
+
serialize(async () => {
|
|
173
|
+
await runtime?.stop();
|
|
174
|
+
runtime = null;
|
|
175
|
+
}),
|
|
176
|
+
current: () => runtime,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
@@ -9,7 +9,13 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
|
9
9
|
import { tmpdir } from 'node:os';
|
|
10
10
|
import { join } from 'node:path';
|
|
11
11
|
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
connectOutcomeNote,
|
|
14
|
+
isDisplayableUrl,
|
|
15
|
+
localIdentityHint,
|
|
16
|
+
parseDeepLink,
|
|
17
|
+
shareViewUrl,
|
|
18
|
+
} from '../client/panels/CloudBar.jsx';
|
|
13
19
|
|
|
14
20
|
let stub: ReturnType<typeof Bun.serve> | null = null;
|
|
15
21
|
let scratch = '';
|
|
@@ -78,20 +84,277 @@ describe('parseDeepLink — untrusted input, strict shape', () => {
|
|
|
78
84
|
'maude://open/x?code=nothex', // bad code
|
|
79
85
|
'https://evil.example/?code=mhc_' + 'a'.repeat(64), // not maude://
|
|
80
86
|
'maude://join/x/y', // join is not the open shape
|
|
87
|
+
// Over the platform's 40-char project-id cap — a drive-by link never
|
|
88
|
+
// passes through the server-side bound, so arbitrary attacker text would
|
|
89
|
+
// otherwise render in the dialog heading, prose and aria-label.
|
|
90
|
+
`maude://open/${'a'.repeat(41)}?code=mhc_${'a'.repeat(64)}`,
|
|
81
91
|
]) {
|
|
82
92
|
expect(parseDeepLink(bad)).toBeNull();
|
|
83
93
|
}
|
|
94
|
+
// …and exactly 40 is still a real project id.
|
|
95
|
+
expect(
|
|
96
|
+
parseDeepLink(`maude://open/${'a'.repeat(40)}?code=mhc_${'a'.repeat(64)}`)
|
|
97
|
+
).not.toBeNull();
|
|
84
98
|
});
|
|
85
99
|
});
|
|
86
100
|
|
|
87
101
|
describe('shareViewUrl — the viewer’s browser home', () => {
|
|
88
|
-
test('
|
|
102
|
+
test('is the project’s OWN address — the view- gallery is gone (Phase 25 C5)', () => {
|
|
103
|
+
// `view-*` is hard-refused by tenantFromHostname, so the old derivation
|
|
104
|
+
// produced a guaranteed 404 — invisible only because window.open was a no-op
|
|
105
|
+
// in the shell. Making the opener work would have made it a dead menu item.
|
|
89
106
|
expect(shareViewUrl('https://alligators.cloud.maude.sh', 'alligators')).toBe(
|
|
90
|
-
'https://
|
|
107
|
+
'https://alligators.cloud.maude.sh'
|
|
91
108
|
);
|
|
109
|
+
expect(shareViewUrl('https://alligators.cloud.maude.sh', 'alligators')).not.toContain('view-');
|
|
92
110
|
});
|
|
93
111
|
test('falls back to the platform zone on garbage', () => {
|
|
94
|
-
expect(shareViewUrl('nonsense', 'p1')).toBe('https://
|
|
112
|
+
expect(shareViewUrl('nonsense', 'p1')).toBe('https://p1.cloud.maude.sh');
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe('isDisplayableUrl — what may be rendered as a clickable address', () => {
|
|
117
|
+
const CLOUD = 'https://cloud.maude.sh';
|
|
118
|
+
|
|
119
|
+
test('the configured origin and the cloud zone’s front doors pass', () => {
|
|
120
|
+
expect(isDisplayableUrl('https://cloud.maude.sh/activate?code=ABCD', CLOUD)).toBe(true);
|
|
121
|
+
expect(isDisplayableUrl('https://alligators.cloud.maude.sh/', CLOUD)).toBe(true);
|
|
122
|
+
// A self-host origin is legitimate for its OWN addresses.
|
|
123
|
+
expect(isDisplayableUrl('http://127.0.0.1:8788/activate', 'http://127.0.0.1:8788')).toBe(true);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('an off-zone address is never rendered — a scheme check was not enough', () => {
|
|
127
|
+
// The bug this closes: `https://evil.example/activate?code=…` from a hostile
|
|
128
|
+
// or MITM'd control plane passed a protocol-only check and still rendered as
|
|
129
|
+
// a live href with a Copy button, and middle-click never reaches the click
|
|
130
|
+
// handler, let alone the Rust zone lock.
|
|
131
|
+
for (const bad of [
|
|
132
|
+
'https://evil.example/activate?code=x',
|
|
133
|
+
'https://cloud.maude.sh.attacker.example/',
|
|
134
|
+
'javascript:alert(1)',
|
|
135
|
+
'file:///etc/passwd',
|
|
136
|
+
'not a url',
|
|
137
|
+
]) {
|
|
138
|
+
expect(isDisplayableUrl(bad, CLOUD)).toBe(false);
|
|
139
|
+
}
|
|
140
|
+
// Deep paths on a TENANT host are refused here too — same rule as the opener.
|
|
141
|
+
expect(isDisplayableUrl('https://canvas-evil.cloud.maude.sh/stored.svg', CLOUD)).toBe(false);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe('status — names the LOCAL half so a deep link can be read', () => {
|
|
146
|
+
test('reports the open folder and its workspace, and never a credential', async () => {
|
|
147
|
+
const { createCloudEndpoints } = await import('../cloud/endpoints.ts');
|
|
148
|
+
const { mkdirSync } = await import('node:fs');
|
|
149
|
+
const repoRoot = join(scratch, 'alligators-web');
|
|
150
|
+
const designRoot = join(repoRoot, '.design');
|
|
151
|
+
mkdirSync(designRoot, { recursive: true });
|
|
152
|
+
writeFileSync(
|
|
153
|
+
join(designRoot, 'config.json'),
|
|
154
|
+
JSON.stringify({ linkedHub: { url: 'https://alligators.cloud.maude.sh', linkedAt: 1 } })
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
const r = createCloudEndpoints({ paths: { repoRoot, designRoot } }).status();
|
|
158
|
+
expect(r.status).toBe(200);
|
|
159
|
+
const json = r.json as Record<string, unknown>;
|
|
160
|
+
expect(json.project).toBe('alligators-web');
|
|
161
|
+
// No hub credential was stored in this scratch dir, so the link is reported
|
|
162
|
+
// but explicitly UNCORROBORATED — the dialog may not vouch for it.
|
|
163
|
+
expect(json.linkedHub).toEqual({
|
|
164
|
+
url: 'https://alligators.cloud.maude.sh',
|
|
165
|
+
credentialed: false,
|
|
166
|
+
});
|
|
167
|
+
// Signed out here — and the payload must stay free of token material
|
|
168
|
+
// whatever the state (this response reaches the browser surface too).
|
|
169
|
+
expect(json.connected).toBe(false);
|
|
170
|
+
expect(JSON.stringify(json)).not.toContain('token');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test('an unlinked (or malformed-config) folder reports linkedHub: null, not an error', async () => {
|
|
174
|
+
const { createCloudEndpoints } = await import('../cloud/endpoints.ts');
|
|
175
|
+
const { mkdirSync } = await import('node:fs');
|
|
176
|
+
const repoRoot = join(scratch, 'fresh-thing');
|
|
177
|
+
const designRoot = join(repoRoot, '.design');
|
|
178
|
+
mkdirSync(designRoot, { recursive: true });
|
|
179
|
+
writeFileSync(join(designRoot, 'config.json'), '{ not json');
|
|
180
|
+
|
|
181
|
+
const json = createCloudEndpoints({ paths: { repoRoot, designRoot } }).status().json as Record<
|
|
182
|
+
string,
|
|
183
|
+
unknown
|
|
184
|
+
>;
|
|
185
|
+
expect(json.linkedHub).toBeNull();
|
|
186
|
+
expect(json.project).toBe('fresh-thing');
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
describe('localIdentityHint — only an exact, corroborated agreement is silent', () => {
|
|
191
|
+
const CLOUD = 'https://cloud.maude.sh';
|
|
192
|
+
|
|
193
|
+
test('the reported case warns: one project open, the link naming another', () => {
|
|
194
|
+
// StudyFi open, "Open in Maude" pressed on alligators — one click from
|
|
195
|
+
// syncing StudyFi's designs into somebody else's workspace.
|
|
196
|
+
expect(
|
|
197
|
+
localIdentityHint({ localProject: 'AI-StudyMate', cloudUrl: CLOUD, claimed: 'alligators' })
|
|
198
|
+
).toBe('mismatch');
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test('a name an ATTACKER chose to look like yours does not buy silence', () => {
|
|
202
|
+
// The whole point (attacker pass B1). The cloud project id is registerable by
|
|
203
|
+
// anyone, so treating "contains your folder name" as agreement handed the
|
|
204
|
+
// attacker the off-switch for the warning. And the server's 409 cannot help:
|
|
205
|
+
// the claim and the actual project agree — it IS their project.
|
|
206
|
+
for (const claimed of [
|
|
207
|
+
'alligators-design-sync',
|
|
208
|
+
'alligators-backup',
|
|
209
|
+
'shared-alligators',
|
|
210
|
+
'web-design-studio-app',
|
|
211
|
+
]) {
|
|
212
|
+
expect(localIdentityHint({ localProject: 'alligators', cloudUrl: CLOUD, claimed })).not.toBe(
|
|
213
|
+
'match'
|
|
214
|
+
);
|
|
215
|
+
expect(
|
|
216
|
+
['similar', 'mismatch'].includes(
|
|
217
|
+
localIdentityHint({ localProject: 'alligators', cloudUrl: CLOUD, claimed })
|
|
218
|
+
)
|
|
219
|
+
).toBe(true);
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test('an exact name is the only silent name-based verdict', () => {
|
|
224
|
+
expect(
|
|
225
|
+
localIdentityHint({ localProject: 'alligators', cloudUrl: CLOUD, claimed: 'alligators' })
|
|
226
|
+
).toBe('match');
|
|
227
|
+
// Normalization still applies — casing and separators are not a difference.
|
|
228
|
+
expect(
|
|
229
|
+
localIdentityHint({ localProject: 'Alligators', cloudUrl: CLOUD, claimed: 'alligators' })
|
|
230
|
+
).toBe('match');
|
|
231
|
+
// A near-match is its own state: alike, and still NOT the same workspace.
|
|
232
|
+
expect(
|
|
233
|
+
localIdentityHint({ localProject: 'alligators-web', cloudUrl: CLOUD, claimed: 'alligators' })
|
|
234
|
+
).toBe('similar');
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test('a CREDENTIALED link to that project reads as linked', () => {
|
|
238
|
+
expect(
|
|
239
|
+
localIdentityHint({
|
|
240
|
+
localProject: 'anything',
|
|
241
|
+
linkedHubUrl: 'https://alligators.cloud.maude.sh',
|
|
242
|
+
linkedHubCredentialed: true,
|
|
243
|
+
cloudUrl: CLOUD,
|
|
244
|
+
claimed: 'alligators',
|
|
245
|
+
})
|
|
246
|
+
).toBe('linked');
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test('an UNCREDENTIALED linkedHub cannot vouch for anyone', () => {
|
|
250
|
+
// config.json is committed and travels with the repo, so a hostile template
|
|
251
|
+
// could otherwise make the dialog say "already linked to <attacker>" — the
|
|
252
|
+
// strongest reassurance it can give (attacker pass B2). Without a stored
|
|
253
|
+
// credential it is ignored and the folder name decides.
|
|
254
|
+
expect(
|
|
255
|
+
localIdentityHint({
|
|
256
|
+
localProject: 'my-thing',
|
|
257
|
+
linkedHubUrl: 'https://attacker.cloud.maude.sh',
|
|
258
|
+
linkedHubCredentialed: false,
|
|
259
|
+
cloudUrl: CLOUD,
|
|
260
|
+
claimed: 'attacker',
|
|
261
|
+
})
|
|
262
|
+
).toBe('mismatch');
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test('a folder credentialed to a DIFFERENT workspace warns', () => {
|
|
266
|
+
expect(
|
|
267
|
+
localIdentityHint({
|
|
268
|
+
localProject: 'alligators',
|
|
269
|
+
linkedHubUrl: 'https://someone-else.cloud.maude.sh',
|
|
270
|
+
linkedHubCredentialed: true,
|
|
271
|
+
cloudUrl: CLOUD,
|
|
272
|
+
claimed: 'alligators',
|
|
273
|
+
})
|
|
274
|
+
).toBe('mismatch');
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test('a short generic folder name does not buy agreement by containment', () => {
|
|
278
|
+
for (const localProject of ['web', 'app', 'src']) {
|
|
279
|
+
expect(
|
|
280
|
+
localIdentityHint({ localProject, cloudUrl: CLOUD, claimed: 'webshop-frontend' })
|
|
281
|
+
).toBe('mismatch');
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test('an accented or over-long folder name still matches its own workspace', () => {
|
|
286
|
+
// The fold must mirror the platform's deriveProjectId (NFKD + strip + 40-cap)
|
|
287
|
+
// or the warning fires on every LEGITIMATE connect for a whole class of
|
|
288
|
+
// names — which is how a consent warning dies (attacker re-review).
|
|
289
|
+
expect(
|
|
290
|
+
localIdentityHint({ localProject: 'Zkušební tým', cloudUrl: CLOUD, claimed: 'zkusebni-tym' })
|
|
291
|
+
).toBe('match');
|
|
292
|
+
expect(
|
|
293
|
+
localIdentityHint({ localProject: 'Přátelé', cloudUrl: CLOUD, claimed: 'pratele' })
|
|
294
|
+
).toBe('match');
|
|
295
|
+
// >40 chars: the platform truncates the id, so the fold must truncate too.
|
|
296
|
+
const long = 'a-very-long-project-name-that-the-platform-truncates-at-forty';
|
|
297
|
+
expect(
|
|
298
|
+
localIdentityHint({ localProject: long, cloudUrl: CLOUD, claimed: long.slice(0, 40) })
|
|
299
|
+
).toBe('match');
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test('no signal is "unknown", which warns — it must never read as agreement', () => {
|
|
303
|
+
// Every stub and self-hoster is http://127.0.0.1:<port>, carrying no project
|
|
304
|
+
// name. Before, that produced a reassuring neutral with a primary Connect;
|
|
305
|
+
// on the launch path an unresolved status did the same (attacker pass B3).
|
|
306
|
+
expect(
|
|
307
|
+
localIdentityHint({
|
|
308
|
+
localProject: '',
|
|
309
|
+
linkedHubUrl: 'http://127.0.0.1:4599',
|
|
310
|
+
cloudUrl: 'http://127.0.0.1:4599',
|
|
311
|
+
claimed: 'stub-project',
|
|
312
|
+
})
|
|
313
|
+
).toBe('unknown');
|
|
314
|
+
// Status not yet resolved → unknown, whatever else is on hand.
|
|
315
|
+
expect(
|
|
316
|
+
localIdentityHint({
|
|
317
|
+
localProject: 'alligators',
|
|
318
|
+
cloudUrl: CLOUD,
|
|
319
|
+
claimed: 'alligators',
|
|
320
|
+
resolved: false,
|
|
321
|
+
})
|
|
322
|
+
).toBe('unknown');
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
describe('connectOutcomeNote — what the rail says after Connect', () => {
|
|
327
|
+
test('syncing: a result, and where to watch it', () => {
|
|
328
|
+
const n = connectOutcomeNote('alligators', { syncing: true, canvases: 12 });
|
|
329
|
+
expect(n.text).toBe('Syncing with alligators — 12 canvases.');
|
|
330
|
+
expect(n.title).toContain('hub sync');
|
|
331
|
+
expect(connectOutcomeNote('alligators', { syncing: true, canvases: 1 }).text).toContain(
|
|
332
|
+
'1 canvas.'
|
|
333
|
+
);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
test('linked but nothing syncable says so — and never claims a restart would help', () => {
|
|
337
|
+
const n = connectOutcomeNote('alligators', {
|
|
338
|
+
syncing: false,
|
|
339
|
+
canvases: 0,
|
|
340
|
+
reason: 'nothing-syncable',
|
|
341
|
+
detail: 'no canvases found under .design.',
|
|
342
|
+
});
|
|
343
|
+
expect(n.text).toBe('Connected to alligators — nothing to sync yet.');
|
|
344
|
+
expect(n.title).toContain('no canvases found');
|
|
345
|
+
expect(n.text).not.toMatch(/restart/i);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
test('any other refusal repeats the server’s own reason', () => {
|
|
349
|
+
const n = connectOutcomeNote('alligators', {
|
|
350
|
+
syncing: false,
|
|
351
|
+
canvases: 0,
|
|
352
|
+
reason: 'no-credential',
|
|
353
|
+
detail: 'No sign-in for this workspace is stored on this machine yet.',
|
|
354
|
+
});
|
|
355
|
+
expect(n.text).toContain('No sign-in for this workspace');
|
|
356
|
+
// Never the old sentence: there is no studio server a person can see.
|
|
357
|
+
expect(n.text).not.toContain('studio server');
|
|
95
358
|
});
|
|
96
359
|
});
|
|
97
360
|
|
|
@@ -148,6 +411,65 @@ describe('attachCode — the maude:// lane end to end (stubbed)', () => {
|
|
|
148
411
|
expect(r.status).toBe(200);
|
|
149
412
|
});
|
|
150
413
|
|
|
414
|
+
test('linking STARTS syncing — the outcome is reported, not homework', async () => {
|
|
415
|
+
// The bug this closes: the panel used to answer a successful Connect with
|
|
416
|
+
// "restart the studio server to start syncing" — a task, naming a thing
|
|
417
|
+
// the desktop user cannot see. The attach lane now cycles the runtime and
|
|
418
|
+
// reports what happened.
|
|
419
|
+
const { createCloudEndpoints } = await import('../cloud/endpoints.ts');
|
|
420
|
+
const designRoot = join(scratch, '.design-sync');
|
|
421
|
+
const { mkdirSync } = await import('node:fs');
|
|
422
|
+
mkdirSync(designRoot, { recursive: true });
|
|
423
|
+
writeFileSync(join(designRoot, 'config.json'), '{}\n');
|
|
424
|
+
|
|
425
|
+
const handed: unknown[] = [];
|
|
426
|
+
const apiEndpoints = createCloudEndpoints({
|
|
427
|
+
paths: { repoRoot: scratch, designRoot },
|
|
428
|
+
syncControl: {
|
|
429
|
+
restart: async (linkedHub) => {
|
|
430
|
+
handed.push(linkedHub);
|
|
431
|
+
return { syncing: true, canvases: 4 };
|
|
432
|
+
},
|
|
433
|
+
},
|
|
434
|
+
});
|
|
435
|
+
const r = await apiEndpoints.attachCode(`mhc_${'a'.repeat(64)}`);
|
|
436
|
+
expect(r.status).toBe(200);
|
|
437
|
+
expect((r.json as any).sync).toEqual({ syncing: true, canvases: 4 });
|
|
438
|
+
expect((r.json as any).note).toBe('Linked — syncing 4 canvases.');
|
|
439
|
+
|
|
440
|
+
// The supervisor is handed the value BY VALUE — the same object written to
|
|
441
|
+
// config.json — so nothing that touches that committed file in between can
|
|
442
|
+
// steer the socket.
|
|
443
|
+
const cfg = JSON.parse(readFileSync(join(designRoot, 'config.json'), 'utf8'));
|
|
444
|
+
expect(handed).toEqual([cfg.linkedHub]);
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
test('a TSX opt-out survives a re-link — only the restrictive direction carries', async () => {
|
|
448
|
+
// `syncTsx: false` means "do not upload canvas bodies to this hub"
|
|
449
|
+
// (DDR-072/DDR-079). Re-linking used to overwrite linkedHub wholesale,
|
|
450
|
+
// which silently re-enabled it — harmless while nothing synced until a
|
|
451
|
+
// restart, a real disclosure now that Connect starts syncing immediately.
|
|
452
|
+
const { createCloudEndpoints } = await import('../cloud/endpoints.ts');
|
|
453
|
+
const designRoot = join(scratch, '.design-opt-out');
|
|
454
|
+
const { mkdirSync } = await import('node:fs');
|
|
455
|
+
mkdirSync(designRoot, { recursive: true });
|
|
456
|
+
writeFileSync(
|
|
457
|
+
join(designRoot, 'config.json'),
|
|
458
|
+
JSON.stringify({ linkedHub: { url: 'https://old.example', linkedAt: 1, syncTsx: false } })
|
|
459
|
+
);
|
|
460
|
+
|
|
461
|
+
const apiEndpoints = createCloudEndpoints({ paths: { repoRoot: scratch, designRoot } });
|
|
462
|
+
const r = await apiEndpoints.attachCode(`mhc_${'a'.repeat(64)}`);
|
|
463
|
+
expect(r.status).toBe(200);
|
|
464
|
+
const cfg = JSON.parse(readFileSync(join(designRoot, 'config.json'), 'utf8'));
|
|
465
|
+
expect(cfg.linkedHub.syncTsx).toBe(false);
|
|
466
|
+
expect(cfg.linkedHub.url).toContain('127.0.0.1');
|
|
467
|
+
// No supervisor wired (a plain embedder): still linked, and the note says
|
|
468
|
+
// what is left to do instead of claiming success.
|
|
469
|
+
expect((r.json as any).sync.syncing).toBe(false);
|
|
470
|
+
expect((r.json as any).note).toMatch(/Restart Maude/);
|
|
471
|
+
});
|
|
472
|
+
|
|
151
473
|
test('a malformed or dead code never reaches the network', async () => {
|
|
152
474
|
const { createCloudEndpoints } = await import('../cloud/endpoints.ts');
|
|
153
475
|
const apiEndpoints = createCloudEndpoints({
|
|
@@ -36,14 +36,31 @@ describe('CSRF Origin guard — sameOriginWrite (DDR-105)', () => {
|
|
|
36
36
|
expect(sameOriginWrite(post('http://localhost:4399.evil.example'))).toBe(false);
|
|
37
37
|
// Right host, wrong port is still cross-origin.
|
|
38
38
|
expect(sameOriginWrite(post('http://localhost:4400'))).toBe(false);
|
|
39
|
-
// Right host:port, wrong scheme is still cross-origin.
|
|
40
|
-
expect(sameOriginWrite(post('https://localhost:4399'))).toBe(false);
|
|
41
39
|
});
|
|
42
40
|
|
|
43
41
|
test('allows the legit same-origin shell Origin', () => {
|
|
44
42
|
expect(sameOriginWrite(post(SELF))).toBe(true);
|
|
45
43
|
});
|
|
46
44
|
|
|
45
|
+
test('a matching host on a different SCHEME is same-origin — TLS terminates at the cell proxy', () => {
|
|
46
|
+
// The second half of the inspector-edits RCA. In a cell the browser sends
|
|
47
|
+
// `https://<host>` but the studio serves plaintext and computes `req.url` as
|
|
48
|
+
// `http://<host>`, so a scheme-strict compare 403'd every legitimate shell
|
|
49
|
+
// write. Host+port still pins it; a cross-site page has a different host.
|
|
50
|
+
const cell = 'https://alligators.cloud.maude.sh';
|
|
51
|
+
const cellReq = (origin?: string) =>
|
|
52
|
+
new Request('http://alligators.cloud.maude.sh/_api/edit-css', {
|
|
53
|
+
method: 'POST',
|
|
54
|
+
headers: { 'content-type': 'application/json', ...(origin ? { origin } : {}) },
|
|
55
|
+
body: '{}',
|
|
56
|
+
});
|
|
57
|
+
expect(sameOriginWrite(cellReq(cell))).toBe(true); // https Origin ↔ http req.url, same host
|
|
58
|
+
// But a genuinely different host is still rejected, scheme notwithstanding.
|
|
59
|
+
expect(sameOriginWrite(cellReq('https://evil.cloud.maude.sh'))).toBe(false);
|
|
60
|
+
// And the port still discriminates when present.
|
|
61
|
+
expect(sameOriginWrite(post('https://localhost:4400'))).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
|
|
47
64
|
test('allows a request with no Origin header (curl / programmatic / bun:test)', () => {
|
|
48
65
|
expect(sameOriginWrite(post())).toBe(true);
|
|
49
66
|
});
|