Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
ea16367
feat(contracts): add projects.createUploadUrl for workspace uploads
msegec Aug 24, 2026
552e1a0
feat(server): sign and store workspace file uploads
msegec Aug 24, 2026
774d836
docs(server): name workspace uploads among signing-key users
msegec Aug 24, 2026
04c5fe4
feat(server): serve workspace uploads over signed POST route
msegec Aug 24, 2026
a0fd65d
feat(web): queue workspace file uploads
msegec Aug 24, 2026
493bdf4
feat(web): upload files from the files view
msegec Aug 24, 2026
4d68e1d
perf(web): scope files-panel renders to its own uploads
msegec Aug 24, 2026
fed2a7a
fix(web,server): harden workspace uploads after review
msegec Aug 25, 2026
b298d64
fix(web,server): close upload races and dedupe the drop overlay
msegec Aug 25, 2026
77417ba
fix(server): re-check upload containment against canonical paths
msegec Aug 25, 2026
ba78a78
fix(server,web): guard mkdir containment and derive upload error mess…
msegec Aug 25, 2026
eee3f4d
style(server): recover tagged failure with catchTags
msegec Aug 25, 2026
b3ab20b
fix(server): harden upload part-file naming, dot-dir paths, and inter…
msegec Aug 25, 2026
fe4aca0
fix(web): keep a finished upload out of the failed state when its ref…
msegec Aug 25, 2026
3ec000c
fix(contracts): raise the upload url bound to fit a PATH_MAX cwd
msegec Aug 25, 2026
3e4a3fe
fix(web,server): harden workspace uploads after second review
msegec Aug 25, 2026
0fe9098
fix(server): finalize non-overwrite uploads without hard links
msegec Aug 25, 2026
c151b48
fix(server): keep failed non-overwrite uploads from stranding a parti…
msegec Aug 25, 2026
b630758
fix(server): upload fallback claims the target before writing
msegec Aug 25, 2026
28bfbba
fix(server,web): make the upload fallback atomic and trim upload names
msegec Aug 25, 2026
2b6d453
fix(server): reclaim the fallback claim by inode, not size alone
msegec Aug 25, 2026
433f128
fix(web): re-confirm overwrite on upload retry
msegec Aug 25, 2026
55ccef5
fix(server): reclaim the upload claim when the post-claim stat fails
msegec Aug 25, 2026
1466512
fix(server): remove only a zero-byte claim when the claim stat fails
msegec Aug 25, 2026
d37af97
feat(contracts): rename and delete workspace entries
msegec Aug 25, 2026
14c82ab
feat(server): rename and delete workspace files
msegec Aug 25, 2026
859075d
feat(web): rename and delete from the files view
msegec Aug 25, 2026
e14ef6c
fix(server): keep rename from destroying data under concurrency
msegec Aug 25, 2026
f3ecbae
fix(server): remove the source entry when renaming onto a hard link
msegec Aug 25, 2026
90b838e
fix(server): treat renaming a file onto its own path as a no-op
msegec Aug 25, 2026
ad74231
fix(web): keep open file surfaces in sync with rename and delete
msegec Aug 25, 2026
9105b07
fix(server): reject renaming onto a symlink that points at the source
msegec Aug 25, 2026
ec34b44
fix(contracts): make cause optional on rename and delete errors
msegec Aug 25, 2026
da1aefa
fix(web): keep editor state consistent through delete and rename
msegec Aug 25, 2026
c5263de
fix(contracts,server): typed escapes-root stage for rename and delete
msegec Aug 25, 2026
a08d457
refactor(contracts,web): derive the rename target-exists check from t…
msegec Aug 25, 2026
aae7578
fix(server): renaming onto a hard link reports target exists
msegec Aug 25, 2026
3c1a9f4
fix(web): drop pending editor saves before rename or delete runs
msegec Aug 25, 2026
740090b
fix(server): rename files on volumes that reject hard links
msegec Aug 25, 2026
e6cc66f
fix(web): failed rename or delete no longer drops pending edits
msegec Aug 25, 2026
9a54328
fix(server): close rename fallback races and delete symlinks by entry
msegec Aug 25, 2026
87512ce
fix(web): one mutation lane per project and reset previews on overwrite
msegec Aug 25, 2026
afe9a82
fix(server): guard rename reclaim by inode and resolve third-casing r…
msegec Aug 25, 2026
43fffaf
fix(web): hold saves during overwrite uploads and refresh image previews
msegec Aug 25, 2026
6902719
fix(server): keep a rival's file when it lands under the source mid-r…
msegec Aug 25, 2026
1d3ba6b
fix(web): hold saves from conflict discovery and track saved revisions
msegec Aug 25, 2026
2e35887
fix(server): guard the rename claim reclaim against a rival overwrite
msegec Aug 25, 2026
c0ff793
fix(web): keep scroll target and panel state when a rename swaps the tab
msegec Aug 25, 2026
a258b87
fix(web): drop in-flight save results once reset replaces the file
msegec Aug 25, 2026
13f02b6
fix(web): keep overlapping mutation holds when reset settles one
msegec Aug 25, 2026
2364ae1
test(server): stage rival rename fixtures
msegec Aug 25, 2026
10d8136
fix(server): type lstat failures
msegec Aug 25, 2026
90e9527
fix(web): a lost upload race prompts to replace instead of failing
msegec Aug 25, 2026
b83839e
fix(web): a lost upload race prompts to replace instead of failing
msegec Aug 25, 2026
d1d7ad0
merge: restack rename/delete on the upload branch after the 409 fix
msegec Aug 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/server/src/auth/RpcAuthorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,9 @@ export const RPC_REQUIRED_SCOPES = {
[WS_METHODS.projectsSearchContents]: AuthOrchestrationReadScope,
[WS_METHODS.projectsSearchEntries]: AuthOrchestrationReadScope,
[WS_METHODS.projectsWriteFile]: AuthOrchestrationOperateScope,
[WS_METHODS.projectsCreateUploadUrl]: AuthOrchestrationOperateScope,
[WS_METHODS.projectsRenameEntry]: AuthOrchestrationOperateScope,
[WS_METHODS.projectsDeleteEntry]: AuthOrchestrationOperateScope,
[WS_METHODS.shellOpenInEditor]: AuthOrchestrationOperateScope,
[WS_METHODS.filesystemBrowse]: AuthOrchestrationReadScope,
[WS_METHODS.assetsCreateUrl]: AuthOrchestrationReadScope,
Expand Down
54 changes: 54 additions & 0 deletions apps/server/src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ import {
storeAttachmentUpload,
validateAttachmentUploadToken,
} from "./assets/AttachmentUpload.ts";
import {
WORKSPACE_UPLOAD_ROUTE_PREFIX,
storeWorkspaceUpload,
validateWorkspaceUploadToken,
} from "./workspace/WorkspaceUpload.ts";
import * as BrowserTraceCollector from "./observability/BrowserTraceCollector.ts";
import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";
import { traceRelayRequest } from "./cloud/traceRelayRequest.ts";
Expand Down Expand Up @@ -280,6 +285,55 @@ export const attachmentUploadRouteLayer = HttpRouter.add(
}),
);

export const workspaceUploadRouteLayer = HttpRouter.add(
"POST",
`${WORKSPACE_UPLOAD_ROUTE_PREFIX}/*`,
Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest;
const url = HttpServerRequest.toURL(request);
if (Option.isNone(url)) {
return HttpServerResponse.text("Bad Request", { status: 400 });
}

const token = url.value.pathname.slice(`${WORKSPACE_UPLOAD_ROUTE_PREFIX}/`.length);
if (!token) {
return HttpServerResponse.text("Not Found", { status: 404 });
}
const claims = yield* validateWorkspaceUploadToken(token);
if (!claims) {
return HttpServerResponse.text("Not Found", { status: 404 });
}

const contentLengthHeader = request.headers["content-length"];
if (
contentLengthHeader !== undefined &&
(!Number.isInteger(Number(contentLengthHeader)) ||
Number(contentLengthHeader) !== claims.sizeBytes)
) {
return HttpServerResponse.text("Content-Length must match the upload size.", {
status: 400,
});
}

// NodeStream.toArrayBuffer treats a falsy maxBytes as "no limit", so a
// 0-byte claim (empty files are a valid upload) would otherwise disable
// the body limit entirely. Floor it at 1 byte; an empty body still passes.
const maxBodySize = FileSystem.Size(Math.max(claims.sizeBytes, 1));
const body = yield* request.arrayBuffer.pipe(
Effect.provideService(HttpServerRequest.MaxBodySize, maxBodySize),
Effect.orElseSucceed(() => null),
);
if (body === null) {
return HttpServerResponse.text("Failed to read the upload body.", { status: 400 });
}

const stored = yield* storeWorkspaceUpload(claims, new Uint8Array(body));
return stored.ok
? HttpServerResponse.empty({ status: 204 })
: HttpServerResponse.text(stored.detail, { status: stored.status });
}),
);

export const staticAndDevRouteLayer = HttpRouter.add(
"GET",
"*",
Expand Down
120 changes: 120 additions & 0 deletions apps/server/src/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ import * as ServerEnvironment from "./environment/ServerEnvironment.ts";
import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts";
import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts";
import * as WorkspacePaths from "./workspace/WorkspacePaths.ts";
import { WORKSPACE_UPLOAD_ROUTE_PREFIX } from "./workspace/WorkspaceUpload.ts";
import * as GitVcsDriver from "./vcs/GitVcsDriver.ts";
import * as VcsDriver from "./vcs/VcsDriver.ts";
import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts";
Expand Down Expand Up @@ -5127,6 +5128,125 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
}).pipe(Effect.provide(NodeHttpServer.layerTest)),
);

it.effect("uploads workspace file bytes through a signed URL issued by websocket rpc", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const workspaceDir = yield* fs.makeTempDirectoryScoped({
prefix: "t3-ws-project-upload-",
});

yield* buildAppUnderTest();

const wsUrl = yield* getWsServerUrl("/ws");
yield* Effect.scoped(
withWsRpcClient(wsUrl, (client) =>
Effect.gen(function* () {
const issued = yield* client[WS_METHODS.projectsCreateUploadUrl]({
cwd: workspaceDir,
relativePath: "uploaded/dropped.bin",
sizeBytes: 6,
overwrite: false,
});
assert.equal(issued.relativePath, "uploaded/dropped.bin");

const badContentLength = yield* HttpClient.post(issued.relativeUrl, {
body: HttpBody.uint8Array(new Uint8Array([1, 2, 3]), "application/octet-stream"),
});
assert.equal(badContentLength.status, 400);

const response = yield* HttpClient.post(issued.relativeUrl, {
body: HttpBody.uint8Array(
new Uint8Array([1, 2, 3, 4, 5, 6]),
"application/octet-stream",
),
});
assert.equal(response.status, 204);

const persisted = yield* fs.readFile(
path.join(workspaceDir, "uploaded", "dropped.bin"),
);
assert.deepEqual(Array.from(persisted), [1, 2, 3, 4, 5, 6]);

const notFoundResponse = yield* HttpClient.post(
`${WORKSPACE_UPLOAD_ROUTE_PREFIX}/not-a-real-token`,
{ body: HttpBody.uint8Array(new Uint8Array([1, 2, 3, 4, 5, 6])) },
);
assert.equal(notFoundResponse.status, 404);

const conflictTarget = yield* client[WS_METHODS.projectsCreateUploadUrl]({
cwd: workspaceDir,
relativePath: "uploaded/conflict.bin",
sizeBytes: 6,
overwrite: false,
});
yield* fs.writeFile(
path.join(workspaceDir, "uploaded", "conflict.bin"),
new Uint8Array([9, 9, 9, 9, 9, 9]),
);
const conflictResponse = yield* HttpClient.post(conflictTarget.relativeUrl, {
body: HttpBody.uint8Array(
new Uint8Array([1, 2, 3, 4, 5, 6]),
"application/octet-stream",
),
});
assert.equal(conflictResponse.status, 409);
}),
),
);
}).pipe(Effect.provide(NodeHttpServer.layerTest)),
);

it.effect("floors the workspace upload body limit for zero-byte claims", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const workspaceDir = yield* fs.makeTempDirectoryScoped({
prefix: "t3-ws-project-upload-empty-",
});

yield* buildAppUnderTest();

const wsUrl = yield* getWsServerUrl("/ws");
yield* Effect.scoped(
withWsRpcClient(wsUrl, (client) =>
Effect.gen(function* () {
const emptyTarget = yield* client[WS_METHODS.projectsCreateUploadUrl]({
cwd: workspaceDir,
relativePath: "empty.bin",
sizeBytes: 0,
overwrite: false,
});
const emptyResponse = yield* HttpClient.post(emptyTarget.relativeUrl, {
body: HttpBody.uint8Array(new Uint8Array(0), "application/octet-stream"),
});
assert.equal(emptyResponse.status, 204);
const emptyPath = path.join(workspaceDir, "empty.bin");
assert.isTrue(yield* fs.exists(emptyPath));
assert.equal((yield* fs.readFile(emptyPath)).byteLength, 0);

// Mint a second zero-byte claim and post a chunked body (no
// Content-Length, so the header check above is skipped) that
// exceeds it. NodeStream.toArrayBuffer treats a falsy maxBytes
// as unlimited, so this only fails once the limit is floored at
// 1 byte.
const oversizedTarget = yield* client[WS_METHODS.projectsCreateUploadUrl]({
cwd: workspaceDir,
relativePath: "empty-oversized.bin",
sizeBytes: 0,
overwrite: false,
});
const oversizedResponse = yield* HttpClient.post(oversizedTarget.relativeUrl, {
body: HttpBody.stream(Stream.make(new Uint8Array([1, 2, 3, 4, 5, 6]))),
});
assert.equal(oversizedResponse.status, 400);
assert.isFalse(yield* fs.exists(path.join(workspaceDir, "empty-oversized.bin")));
}),
),
);
}).pipe(Effect.provide(NodeHttpServer.layerTest)),
);

it.effect("creates a missing workspace root during websocket project.create dispatch", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
staticAndDevRouteLayer,
browserApiCorsLayer,
httpCompressionLayer,
workspaceUploadRouteLayer,
} from "./http.ts";
import { guardHttpResponseWriteErrors } from "./httpResponseErrorGuard.ts";
import { fixPath } from "./os-jank.ts";
Expand Down Expand Up @@ -458,6 +459,7 @@ export const makeRoutesLayer = Layer.mergeAll(
otlpTracesProxyRouteLayer,
assetRouteLayer,
attachmentUploadRouteLayer,
workspaceUploadRouteLayer,
staticAndDevRouteLayer,
websocketRpcRouteLayer,
),
Expand Down
Loading
Loading