@1agh/maude 0.48.0 → 0.49.1
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/api.ts +18 -2
- package/apps/studio/assets-s3.ts +291 -0
- 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 +1 -1
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/http.ts +45 -0
- 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/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/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/commands/hub-workspace.mjs +341 -0
- package/cli/commands/hub.mjs +325 -3
- package/cli/commands/hub.test.mjs +14 -1
- package/cli/lib/cell-plan.mjs +302 -0
- package/cli/lib/cell-plan.test.mjs +225 -0
- package/cli/lib/gitignore-block.mjs +12 -1
- package/cli/lib/workspace-plan.mjs +422 -0
- package/cli/lib/workspace-plan.test.mjs +223 -0
- package/package.json +8 -8
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
// "Sign in to workspace" — Cloud Phase 3 Task 3.
|
|
2
|
+
//
|
|
3
|
+
// The Phase-2 hub can authenticate a PERSON (`POST /auth/login` → a scoped,
|
|
4
|
+
// expiring peer token). This is the client half: the flow that turns "my
|
|
5
|
+
// teammate sent me an address" into a working, synced project without the
|
|
6
|
+
// person ever seeing the word "token".
|
|
7
|
+
//
|
|
8
|
+
// The vocabulary rule is not cosmetic (DDR-193 §5). The persona this product
|
|
9
|
+
// lives or dies by is the invited teammate who has never used git. Every string
|
|
10
|
+
// on this path says "sign in", "workspace", "project" — never token, never
|
|
11
|
+
// repository, never OAuth. A flow that says "paste your bearer token" has
|
|
12
|
+
// already told that person the product is not for them.
|
|
13
|
+
//
|
|
14
|
+
// TOKEN CUSTODY (unchanged from DDR-054 / the phase-29 A2 finding, and the
|
|
15
|
+
// reason this file reuses hub-link.ts rather than writing its own storage):
|
|
16
|
+
//
|
|
17
|
+
// • the health probe is TOKENLESS — a lookalike address must never receive a
|
|
18
|
+
// credential;
|
|
19
|
+
// • the password goes ONLY to the address the user typed, once, and is never
|
|
20
|
+
// stored anywhere, in memory or on disk, after the request;
|
|
21
|
+
// • what IS stored is the minted peer token, in `~/.config/maude/hubs.json`
|
|
22
|
+
// at mode 0600, keyed by normalized URL — the same store `maude design
|
|
23
|
+
// link` writes, so a project opened later just works.
|
|
24
|
+
//
|
|
25
|
+
// The token now EXPIRES (Phase 2), which changes one thing for the client: a
|
|
26
|
+
// sync failure can mean "your session ran out" rather than "something broke",
|
|
27
|
+
// and the honest response is to ask the person to sign in again rather than to
|
|
28
|
+
// silently retry forever.
|
|
29
|
+
|
|
30
|
+
import { saveHubCredential } from './hub-link.ts';
|
|
31
|
+
import { normalizeUrl } from './hubs-config.ts';
|
|
32
|
+
|
|
33
|
+
const PROBE_TIMEOUT_MS = 4000;
|
|
34
|
+
const LOGIN_TIMEOUT_MS = 15_000;
|
|
35
|
+
|
|
36
|
+
export interface SignInResult {
|
|
37
|
+
status: number;
|
|
38
|
+
json:
|
|
39
|
+
| {
|
|
40
|
+
ok: true;
|
|
41
|
+
url: string;
|
|
42
|
+
/** Plain-words identity for the UI header — never a token. */
|
|
43
|
+
user: { email: string; role: string };
|
|
44
|
+
/** ms-epoch. The UI surfaces "signed in until <date>", not a raw number. */
|
|
45
|
+
expiresAt: number | null;
|
|
46
|
+
version: string | null;
|
|
47
|
+
}
|
|
48
|
+
| { ok: false; error: string; reason?: SignInFailure };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export type SignInFailure =
|
|
52
|
+
| 'bad-address'
|
|
53
|
+
| 'unreachable'
|
|
54
|
+
| 'not-a-maude-workspace'
|
|
55
|
+
| 'bad-credentials'
|
|
56
|
+
| 'rate-limited'
|
|
57
|
+
| 'save-failed'
|
|
58
|
+
| 'server-error';
|
|
59
|
+
|
|
60
|
+
export interface SignInInput {
|
|
61
|
+
url?: unknown;
|
|
62
|
+
email?: unknown;
|
|
63
|
+
password?: unknown;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Injected in tests. */
|
|
67
|
+
export interface SignInDeps {
|
|
68
|
+
fetchImpl?: typeof fetch;
|
|
69
|
+
save?: (normUrl: string, token: string) => void;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Address → reachability → sign in → credential stored. One call, because the
|
|
74
|
+
* person doing this is not debugging a network stack; they were sent a link.
|
|
75
|
+
*/
|
|
76
|
+
export async function signInToWorkspace(
|
|
77
|
+
input: SignInInput,
|
|
78
|
+
deps: SignInDeps = {}
|
|
79
|
+
): Promise<SignInResult> {
|
|
80
|
+
const doFetch = deps.fetchImpl ?? fetch;
|
|
81
|
+
const save = deps.save ?? saveHubCredential;
|
|
82
|
+
|
|
83
|
+
if (typeof input.url !== 'string' || !input.url.trim()) {
|
|
84
|
+
return fail(400, 'Enter the workspace address your team sent you.', 'bad-address');
|
|
85
|
+
}
|
|
86
|
+
if (typeof input.email !== 'string' || !input.email.trim()) {
|
|
87
|
+
return fail(400, 'Enter your email address.', 'bad-credentials');
|
|
88
|
+
}
|
|
89
|
+
if (typeof input.password !== 'string' || input.password === '') {
|
|
90
|
+
return fail(400, 'Enter your password.', 'bad-credentials');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
let norm: string;
|
|
94
|
+
try {
|
|
95
|
+
norm = normalizeUrl(input.url.trim());
|
|
96
|
+
const parsed = new URL(norm);
|
|
97
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
98
|
+
return fail(400, 'The workspace address must start with http:// or https://.', 'bad-address');
|
|
99
|
+
}
|
|
100
|
+
} catch {
|
|
101
|
+
return fail(400, "That doesn't look like a workspace address.", 'bad-address');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// 1. TOKENLESS reachability probe. Also tells us it is a Maude workspace at
|
|
105
|
+
// all, so a typo'd address fails here with a sentence a person can act on
|
|
106
|
+
// instead of failing later as a mysterious auth error.
|
|
107
|
+
const probe = await probe200(doFetch, `${norm}/health`, PROBE_TIMEOUT_MS);
|
|
108
|
+
if (!probe.reached) {
|
|
109
|
+
return fail(
|
|
110
|
+
502,
|
|
111
|
+
"Couldn't reach that workspace. Check the address, or ask whoever invited you whether it's online.",
|
|
112
|
+
'unreachable'
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
if (!probe.isMaude) {
|
|
116
|
+
return fail(
|
|
117
|
+
502,
|
|
118
|
+
"That address answered, but it isn't a Maude workspace. Double-check the link you were sent.",
|
|
119
|
+
'not-a-maude-workspace'
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// 2. Sign in. The password goes to this address once and is never retained.
|
|
124
|
+
let res: Response;
|
|
125
|
+
try {
|
|
126
|
+
const ctrl = new AbortController();
|
|
127
|
+
const timer = setTimeout(() => ctrl.abort(), LOGIN_TIMEOUT_MS);
|
|
128
|
+
try {
|
|
129
|
+
res = await doFetch(`${norm}/auth/login`, {
|
|
130
|
+
method: 'POST',
|
|
131
|
+
headers: { 'Content-Type': 'application/json' },
|
|
132
|
+
body: JSON.stringify({ email: input.email.trim(), password: input.password }),
|
|
133
|
+
signal: ctrl.signal,
|
|
134
|
+
});
|
|
135
|
+
} finally {
|
|
136
|
+
clearTimeout(timer);
|
|
137
|
+
}
|
|
138
|
+
} catch {
|
|
139
|
+
return fail(502, "Couldn't reach that workspace to sign in.", 'unreachable');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (res.status === 401) {
|
|
143
|
+
// The hub deliberately cannot tell us WHICH part was wrong (it refuses to be
|
|
144
|
+
// a user-existence oracle), so neither can we. Saying "wrong password" when
|
|
145
|
+
// the account may not exist would be a guess presented as fact.
|
|
146
|
+
return fail(401, 'That email and password combination was not accepted.', 'bad-credentials');
|
|
147
|
+
}
|
|
148
|
+
if (res.status === 429) {
|
|
149
|
+
return fail(429, 'Too many attempts. Wait a minute and try again.', 'rate-limited');
|
|
150
|
+
}
|
|
151
|
+
if (!res.ok) {
|
|
152
|
+
return fail(502, 'The workspace refused the sign-in. Try again shortly.', 'server-error');
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
let body: {
|
|
156
|
+
token?: unknown;
|
|
157
|
+
expiresAt?: unknown;
|
|
158
|
+
user?: { email?: unknown; role?: unknown };
|
|
159
|
+
};
|
|
160
|
+
try {
|
|
161
|
+
body = (await res.json()) as typeof body;
|
|
162
|
+
} catch {
|
|
163
|
+
return fail(502, 'The workspace sent an unexpected reply.', 'server-error');
|
|
164
|
+
}
|
|
165
|
+
if (typeof body.token !== 'string' || !body.token) {
|
|
166
|
+
return fail(502, 'The workspace sent an unexpected reply.', 'server-error');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// 3. Persist ONLY the minted token, 0600, in the shared credential store.
|
|
170
|
+
try {
|
|
171
|
+
save(norm, body.token);
|
|
172
|
+
} catch {
|
|
173
|
+
return fail(500, "Couldn't save the workspace connection on this computer.", 'save-failed');
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
status: 200,
|
|
178
|
+
json: {
|
|
179
|
+
ok: true,
|
|
180
|
+
url: norm,
|
|
181
|
+
user: {
|
|
182
|
+
email: typeof body.user?.email === 'string' ? body.user.email : String(input.email).trim(),
|
|
183
|
+
role: typeof body.user?.role === 'string' ? body.user.role : 'member',
|
|
184
|
+
},
|
|
185
|
+
expiresAt: typeof body.expiresAt === 'number' ? body.expiresAt : null,
|
|
186
|
+
version: probe.version,
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function fail(status: number, error: string, reason: SignInFailure): SignInResult {
|
|
192
|
+
return { status, json: { ok: false, error, reason } };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function probe200(
|
|
196
|
+
doFetch: typeof fetch,
|
|
197
|
+
url: string,
|
|
198
|
+
timeoutMs: number
|
|
199
|
+
): Promise<{ reached: boolean; isMaude: boolean; version: string | null }> {
|
|
200
|
+
const ctrl = new AbortController();
|
|
201
|
+
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
202
|
+
try {
|
|
203
|
+
const res = await doFetch(url, { signal: ctrl.signal });
|
|
204
|
+
if (!res.ok) return { reached: true, isMaude: false, version: null };
|
|
205
|
+
try {
|
|
206
|
+
const j = (await res.json()) as { ok?: unknown; version?: unknown };
|
|
207
|
+
// A Maude hub's /health always carries `ok`. Anything else answered on
|
|
208
|
+
// that path is some other service.
|
|
209
|
+
const isMaude = j?.ok === true;
|
|
210
|
+
return {
|
|
211
|
+
reached: true,
|
|
212
|
+
isMaude,
|
|
213
|
+
version: typeof j?.version === 'string' ? j.version : null,
|
|
214
|
+
};
|
|
215
|
+
} catch {
|
|
216
|
+
return { reached: true, isMaude: false, version: null };
|
|
217
|
+
}
|
|
218
|
+
} catch {
|
|
219
|
+
return { reached: false, isMaude: false, version: null };
|
|
220
|
+
} finally {
|
|
221
|
+
clearTimeout(timer);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ---------------------------------------------------------------- disclosure
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* What the operator of a workspace can see and do — DDR-054's trust model, made
|
|
229
|
+
* visible to the person it concerns.
|
|
230
|
+
*
|
|
231
|
+
* DDR-079 disclosed TSX-sync via a terminal banner. In a workspace the affected
|
|
232
|
+
* human may never open a terminal: invitees run Maude Desktop and nothing else.
|
|
233
|
+
* So the disclosure moves to where they are. The banner stays for terminal
|
|
234
|
+
* users; it is no longer the only disclosure (DDR-192 §6).
|
|
235
|
+
*
|
|
236
|
+
* Written as data rather than JSX so the same sentences appear in the desktop
|
|
237
|
+
* panel, the web onboarding, and the docs without three drifting copies — and
|
|
238
|
+
* so a test can assert the honest ones are still present.
|
|
239
|
+
*/
|
|
240
|
+
export interface DisclosureItem {
|
|
241
|
+
/** Short label for the row. */
|
|
242
|
+
title: string;
|
|
243
|
+
/** One plain sentence. No jargon, no hedging. */
|
|
244
|
+
detail: string;
|
|
245
|
+
/** `sees` = the operator can read it · `cannot` = they can't · `you` = you control it. */
|
|
246
|
+
kind: 'sees' | 'cannot' | 'you';
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export function workspaceDisclosure({
|
|
250
|
+
operator,
|
|
251
|
+
aiAvailable,
|
|
252
|
+
}: {
|
|
253
|
+
/** Who runs this workspace, in words: "your team", "Maude Cloud", a hostname. */
|
|
254
|
+
operator: string;
|
|
255
|
+
/** Whether this machine has a Claude subscription wired up (DDR-123). */
|
|
256
|
+
aiAvailable: boolean;
|
|
257
|
+
}): DisclosureItem[] {
|
|
258
|
+
return [
|
|
259
|
+
{
|
|
260
|
+
kind: 'sees',
|
|
261
|
+
title: 'Your designs',
|
|
262
|
+
detail: `${operator} stores every canvas in this project, including its full edit history.`,
|
|
263
|
+
},
|
|
264
|
+
{
|
|
265
|
+
kind: 'sees',
|
|
266
|
+
title: 'Who is working, and when',
|
|
267
|
+
detail: `${operator} can see which files you open and when you are editing.`,
|
|
268
|
+
},
|
|
269
|
+
{
|
|
270
|
+
kind: 'sees',
|
|
271
|
+
title: 'Comments and annotations',
|
|
272
|
+
detail:
|
|
273
|
+
'Anything you write on a canvas is stored in the workspace, not just on your machine.',
|
|
274
|
+
},
|
|
275
|
+
{
|
|
276
|
+
kind: 'cannot',
|
|
277
|
+
title: 'Nothing else on this computer',
|
|
278
|
+
detail:
|
|
279
|
+
'Only this project folder syncs. Other files, other projects, and the rest of your disk are never read.',
|
|
280
|
+
},
|
|
281
|
+
{
|
|
282
|
+
kind: 'cannot',
|
|
283
|
+
title: 'Your designs are never run on their servers',
|
|
284
|
+
detail:
|
|
285
|
+
'Canvases render here, on your machine. The workspace stores and syncs them — it never executes them.',
|
|
286
|
+
},
|
|
287
|
+
{
|
|
288
|
+
kind: aiAvailable ? 'you' : 'cannot',
|
|
289
|
+
title: 'AI runs on your own subscription',
|
|
290
|
+
detail: aiAvailable
|
|
291
|
+
? 'Claude runs locally under your own subscription. Your prompts do not pass through the workspace.'
|
|
292
|
+
: 'AI is off because this computer has no Claude subscription connected. Everything else works normally.',
|
|
293
|
+
},
|
|
294
|
+
{
|
|
295
|
+
kind: 'you',
|
|
296
|
+
title: 'You can leave with everything',
|
|
297
|
+
detail:
|
|
298
|
+
'One click exports the whole project — designs and media — in a form that opens without Maude.',
|
|
299
|
+
},
|
|
300
|
+
];
|
|
301
|
+
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
// Cloud Phase 3 Task 2 — the S3/R2 asset lane.
|
|
2
|
+
//
|
|
3
|
+
// Exercised against a live in-process S3-shaped server rather than MinIO: the
|
|
4
|
+
// properties worth proving are content-addressing, verification of bytes
|
|
5
|
+
// received from a semi-trusted hub, and never failing a local save because a
|
|
6
|
+
// bucket was unreachable. None of those need a storage vendor.
|
|
7
|
+
|
|
8
|
+
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
|
9
|
+
|
|
10
|
+
import { createServer, type Server } from 'node:http';
|
|
11
|
+
import type { AddressInfo } from 'node:net';
|
|
12
|
+
// The HUB's independent SigV4 implementation — imported to pin the two against
|
|
13
|
+
// each other, exactly as the doc-namespace grammars are pinned.
|
|
14
|
+
import { signRequest as hubSignRequest } from '../../hub/src/s3.mjs';
|
|
15
|
+
import {
|
|
16
|
+
createAssetMirror,
|
|
17
|
+
type S3Config,
|
|
18
|
+
s3ConfigFromEnv,
|
|
19
|
+
sha8,
|
|
20
|
+
sha8FromAssetPath,
|
|
21
|
+
signRequest,
|
|
22
|
+
verifyAssetBytes,
|
|
23
|
+
} from '../assets-s3.ts';
|
|
24
|
+
|
|
25
|
+
let server: Server;
|
|
26
|
+
let store: Map<string, Uint8Array>;
|
|
27
|
+
let seenAuth: string[];
|
|
28
|
+
let cfg: S3Config;
|
|
29
|
+
|
|
30
|
+
beforeAll(async () => {
|
|
31
|
+
store = new Map();
|
|
32
|
+
seenAuth = [];
|
|
33
|
+
server = createServer((req, res) => {
|
|
34
|
+
seenAuth.push(req.headers.authorization ?? '');
|
|
35
|
+
const key = decodeURIComponent(
|
|
36
|
+
new URL(req.url ?? '/', 'http://x').pathname.replace('/maude-assets/', '')
|
|
37
|
+
);
|
|
38
|
+
if (req.method === 'PUT') {
|
|
39
|
+
const chunks: Buffer[] = [];
|
|
40
|
+
req.on('data', (c) => chunks.push(c));
|
|
41
|
+
req.on('end', () => {
|
|
42
|
+
store.set(key, new Uint8Array(Buffer.concat(chunks)));
|
|
43
|
+
res.writeHead(200).end();
|
|
44
|
+
});
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (req.method === 'HEAD') {
|
|
48
|
+
res.writeHead(store.has(key) ? 200 : 404).end();
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (req.method === 'GET') {
|
|
52
|
+
const body = store.get(key);
|
|
53
|
+
if (!body) {
|
|
54
|
+
res.writeHead(404).end();
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
res.writeHead(200).end(Buffer.from(body));
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
res.writeHead(405).end();
|
|
61
|
+
});
|
|
62
|
+
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', () => resolve()));
|
|
63
|
+
cfg = {
|
|
64
|
+
endpoint: `http://127.0.0.1:${(server.address() as AddressInfo).port}`,
|
|
65
|
+
bucket: 'maude-assets',
|
|
66
|
+
accessKeyId: 'AKIAEXAMPLE',
|
|
67
|
+
secretAccessKey: 'secret',
|
|
68
|
+
region: 'auto',
|
|
69
|
+
};
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
afterAll(async () => {
|
|
73
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const bytesFor = (s: string) => new TextEncoder().encode(s);
|
|
77
|
+
const assetPath = (b: Uint8Array, ext = 'png') => `assets/${sha8(b)}.${ext}`;
|
|
78
|
+
|
|
79
|
+
describe('content addressing', () => {
|
|
80
|
+
test('sha8 is stable and differs for different bytes', () => {
|
|
81
|
+
const a = bytesFor('hello');
|
|
82
|
+
expect(sha8(a)).toBe(sha8(new Uint8Array(a)));
|
|
83
|
+
expect(sha8(a)).not.toBe(sha8(bytesFor('hellp')));
|
|
84
|
+
expect(sha8(a)).toMatch(/^[0-9a-f]{8}$/);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('sha8FromAssetPath accepts content-addressed paths and refuses others', () => {
|
|
88
|
+
expect(sha8FromAssetPath('assets/deadbeef.png')).toBe('deadbeef');
|
|
89
|
+
expect(sha8FromAssetPath('assets/deadbeef')).toBe('deadbeef');
|
|
90
|
+
expect(sha8FromAssetPath('assets/deadbeef.mp4')).toBe('deadbeef');
|
|
91
|
+
// The shapes the REAL corpus contains beyond saveAsset's own output — a
|
|
92
|
+
// dotted sidecar and hash+label ingested footage. A stricter pattern
|
|
93
|
+
// classified these as "not content addressed", which made the mirror
|
|
94
|
+
// refuse legitimate assets as unverifiable.
|
|
95
|
+
expect(sha8FromAssetPath('assets/deadbeef.photo.json')).toBe('deadbeef');
|
|
96
|
+
expect(sha8FromAssetPath('assets/deadbeef.footage.json')).toBe('deadbeef');
|
|
97
|
+
expect(sha8FromAssetPath('assets/deadbeef-cloud.mp4')).toBe('deadbeef');
|
|
98
|
+
expect(sha8FromAssetPath('assets/deadbeef-cloud.srt')).toBe('deadbeef');
|
|
99
|
+
// Legacy / hand-placed / traversal shapes are not content-addressed. The
|
|
100
|
+
// 8 hex chars must be a COMPLETE token — `deadbeef1` is a different name.
|
|
101
|
+
expect(sha8FromAssetPath('assets/my-photo.png')).toBe(null);
|
|
102
|
+
expect(sha8FromAssetPath('assets/DEADBEEF.png')).toBe(null);
|
|
103
|
+
expect(sha8FromAssetPath('assets/deadbeef1.png')).toBe(null);
|
|
104
|
+
expect(sha8FromAssetPath('assets/deadbeefcafe.png')).toBe(null);
|
|
105
|
+
expect(sha8FromAssetPath('assets/sub/deadbeef.png')).toBe(null);
|
|
106
|
+
expect(sha8FromAssetPath('../assets/deadbeef.png')).toBe(null);
|
|
107
|
+
expect(sha8FromAssetPath('')).toBe(null);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('verifyAssetBytes is the check that makes a semi-trusted hub usable', () => {
|
|
111
|
+
const bytes = bytesFor('the real image');
|
|
112
|
+
const path = assetPath(bytes);
|
|
113
|
+
expect(verifyAssetBytes(path, bytes)).toBe(true);
|
|
114
|
+
// ...including the labelled and sidecar shapes.
|
|
115
|
+
expect(verifyAssetBytes(`assets/${sha8(bytes)}-cloud.mp4`, bytes)).toBe(true);
|
|
116
|
+
expect(verifyAssetBytes(`assets/${sha8(bytes)}.photo.json`, bytes)).toBe(true);
|
|
117
|
+
// Substituted content under the same name — the attack DDR-054 describes.
|
|
118
|
+
expect(verifyAssetBytes(path, bytesFor('malicious replacement'))).toBe(false);
|
|
119
|
+
// A path with no content address cannot be verified, so it is not trusted.
|
|
120
|
+
expect(verifyAssetBytes('assets/logo.png', bytes)).toBe(false);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe('config', () => {
|
|
125
|
+
test('an unconfigured lane is the default, not an error', () => {
|
|
126
|
+
expect(s3ConfigFromEnv({})).toBe(null);
|
|
127
|
+
// Partial config must not half-enable the lane.
|
|
128
|
+
expect(s3ConfigFromEnv({ MAUDE_S3_BUCKET: 'b', MAUDE_S3_ENDPOINT: 'e' })).toBe(null);
|
|
129
|
+
const mirror = createAssetMirror(null);
|
|
130
|
+
expect(mirror.configured).toBe(false);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('a full env set configures it, trailing slash trimmed, region defaulted', () => {
|
|
134
|
+
const resolved = s3ConfigFromEnv({
|
|
135
|
+
MAUDE_S3_ENDPOINT: 'https://acct.r2.cloudflarestorage.com/',
|
|
136
|
+
MAUDE_S3_BUCKET: 'maude-assets',
|
|
137
|
+
MAUDE_S3_ACCESS_KEY_ID: 'AKIA',
|
|
138
|
+
MAUDE_S3_SECRET_ACCESS_KEY: 'secret',
|
|
139
|
+
});
|
|
140
|
+
expect(resolved?.endpoint).toBe('https://acct.r2.cloudflarestorage.com');
|
|
141
|
+
expect(resolved?.region).toBe('auto');
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe('SigV4 — pinned against the hub implementation', () => {
|
|
146
|
+
test('studio and hub sign the same request identically', () => {
|
|
147
|
+
// Two independent implementations in two independently-built packages. If
|
|
148
|
+
// they drift, one of them is wrong against real R2 and nobody would notice
|
|
149
|
+
// until a deploy.
|
|
150
|
+
const shared = {
|
|
151
|
+
endpoint: 'https://acct.r2.cloudflarestorage.com',
|
|
152
|
+
bucket: 'maude-assets',
|
|
153
|
+
accessKeyId: 'AKIAEXAMPLE',
|
|
154
|
+
secretAccessKey: 'wJalrXUtnFEMI',
|
|
155
|
+
region: 'auto',
|
|
156
|
+
};
|
|
157
|
+
const now = new Date('2026-07-28T20:30:00Z');
|
|
158
|
+
for (const key of ['assets/deadbeef.png', 'assets/a b+c!.png', 'assets/ünïcode.png']) {
|
|
159
|
+
for (const body of [null, bytesFor('payload')]) {
|
|
160
|
+
const mine = signRequest(shared, { method: 'PUT', key, body, now });
|
|
161
|
+
const theirs = hubSignRequest(shared, { method: 'PUT', key, body, now });
|
|
162
|
+
expect(mine.headers.authorization).toBe(theirs.headers.authorization);
|
|
163
|
+
expect(mine.url).toBe(theirs.url);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('the payload is signed, so different bytes give a different signature', () => {
|
|
169
|
+
const now = new Date('2026-07-28T20:30:00Z');
|
|
170
|
+
const a = signRequest(cfg, { method: 'PUT', key: 'assets/x.png', body: bytesFor('a'), now });
|
|
171
|
+
const b = signRequest(cfg, { method: 'PUT', key: 'assets/x.png', body: bytesFor('b'), now });
|
|
172
|
+
expect(a.headers.authorization).not.toBe(b.headers.authorization);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe('the mirror, against a live S3-shaped server', () => {
|
|
177
|
+
test('push then pull round-trips, and every request is signed', async () => {
|
|
178
|
+
const mirror = createAssetMirror(cfg);
|
|
179
|
+
const bytes = bytesFor('a small png, pretend');
|
|
180
|
+
const rel = assetPath(bytes);
|
|
181
|
+
|
|
182
|
+
expect(await mirror.push(rel, bytes)).toBe(true);
|
|
183
|
+
expect(await mirror.has(rel)).toBe(true);
|
|
184
|
+
const pulled = await mirror.pull(rel);
|
|
185
|
+
expect(pulled).not.toBe(null);
|
|
186
|
+
expect([...(pulled ?? [])]).toEqual([...bytes]);
|
|
187
|
+
|
|
188
|
+
expect(seenAuth.length).toBeGreaterThan(0);
|
|
189
|
+
expect(seenAuth.every((a) => a.startsWith('AWS4-HMAC-SHA256 Credential='))).toBe(true);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test('push is idempotent — content addressing means a re-push is a no-op', async () => {
|
|
193
|
+
const mirror = createAssetMirror(cfg);
|
|
194
|
+
const bytes = bytesFor('idempotent content');
|
|
195
|
+
const rel = assetPath(bytes);
|
|
196
|
+
expect(await mirror.push(rel, bytes)).toBe(true);
|
|
197
|
+
const sizeAfterFirst = store.size;
|
|
198
|
+
expect(await mirror.push(rel, bytes)).toBe(true);
|
|
199
|
+
expect(store.size).toBe(sizeAfterFirst);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test('a missing object pulls as null, not an error', async () => {
|
|
203
|
+
const mirror = createAssetMirror(cfg);
|
|
204
|
+
expect(await mirror.pull('assets/00000000.png')).toBe(null);
|
|
205
|
+
expect(await mirror.has('assets/00000000.png')).toBe(false);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test('SUBSTITUTED bytes are REFUSED — the hub cannot swap an asset', async () => {
|
|
209
|
+
// Plant content under a name it does not hash to, exactly as a compromised
|
|
210
|
+
// or malicious hub would. The pull must refuse rather than write it to disk,
|
|
211
|
+
// where it would poison every peer that later mirrors from us.
|
|
212
|
+
const honest = bytesFor('the real asset');
|
|
213
|
+
const rel = assetPath(honest);
|
|
214
|
+
store.set(rel, bytesFor('malicious replacement of the same length!!'));
|
|
215
|
+
|
|
216
|
+
const warnings: string[] = [];
|
|
217
|
+
const mirror = createAssetMirror(cfg, { log: { warn: (m: string) => warnings.push(m) } });
|
|
218
|
+
expect(await mirror.pull(rel)).toBe(null);
|
|
219
|
+
expect(warnings.join(' ')).toMatch(/does not hash to its own name/);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test('an unreachable bucket NEVER fails the caller — it returns false', async () => {
|
|
223
|
+
// The local file is already on disk and in git; the bucket is the redundant
|
|
224
|
+
// copy. Throwing here would turn a network blip into a failed save.
|
|
225
|
+
const dead = createAssetMirror(
|
|
226
|
+
{ ...cfg, endpoint: 'http://127.0.0.1:1' },
|
|
227
|
+
{ log: { warn: () => {} } }
|
|
228
|
+
);
|
|
229
|
+
expect(await dead.push('assets/deadbeef.png', bytesFor('x'))).toBe(false);
|
|
230
|
+
expect(await dead.pull('assets/deadbeef.png')).toBe(null);
|
|
231
|
+
expect(await dead.has('assets/deadbeef.png')).toBe(false);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test('a non-2xx response is handled like a miss, not a crash', async () => {
|
|
235
|
+
const mirror = createAssetMirror(
|
|
236
|
+
{ ...cfg, bucket: 'wrong-bucket' },
|
|
237
|
+
{ log: { warn: () => {} } }
|
|
238
|
+
);
|
|
239
|
+
expect(await mirror.pull('assets/deadbeef.png')).toBe(null);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test('the unconfigured mirror answers safely for every verb', async () => {
|
|
243
|
+
const mirror = createAssetMirror(null);
|
|
244
|
+
expect(await mirror.push('assets/deadbeef.png', bytesFor('x'))).toBe(false);
|
|
245
|
+
expect(await mirror.pull('assets/deadbeef.png')).toBe(null);
|
|
246
|
+
expect(await mirror.has('assets/deadbeef.png')).toBe(false);
|
|
247
|
+
expect(mirror.describe).toBe('none');
|
|
248
|
+
});
|
|
249
|
+
});
|
|
@@ -62,6 +62,13 @@ describe('canvas-origin gate — A1/A2 traversal + privilege containment', () =>
|
|
|
62
62
|
for (const p of [
|
|
63
63
|
'/_config',
|
|
64
64
|
'/_sync-status',
|
|
65
|
+
// Cloud Phase 3 Task 3 — the sign-in handler RECEIVES A PASSWORD, and
|
|
66
|
+
// the disclosure route is main-origin UI chrome. Both are absent from
|
|
67
|
+
// CANVAS_SAFE_API and from startCanvasServer's route map, so a GET
|
|
68
|
+
// 403s at the gate rather than 405-ing from the handler — which is what
|
|
69
|
+
// proves the route is unreachable on this origin at all.
|
|
70
|
+
'/_api/workspace/sign-in',
|
|
71
|
+
'/_api/workspace/disclosure',
|
|
65
72
|
'/_api/export',
|
|
66
73
|
// feature-background-export-notification-center — the background job
|
|
67
74
|
// routes carry the same privileged data class as /_api/export (job
|