Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 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
90e9527
fix(web): a lost upload race prompts to replace instead of failing
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
1 change: 1 addition & 0 deletions apps/server/src/auth/RpcAuthorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ export const RPC_REQUIRED_SCOPES = {
[WS_METHODS.projectsSearchContents]: AuthOrchestrationReadScope,
[WS_METHODS.projectsSearchEntries]: AuthOrchestrationReadScope,
[WS_METHODS.projectsWriteFile]: AuthOrchestrationOperateScope,
[WS_METHODS.projectsCreateUploadUrl]: 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