Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ __pycache__
/build
node_modules
dist
dist-types
*.tsbuildinfo
.output
.nitro
doc
Expand Down
14 changes: 14 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,20 @@ invariant in full, the labels (minimal) and auth (carve-out) shapes,
the pure-policy-vs-framework-shell principle, and when to introduce
`service.ts`.

### Client-reachable files import server modules via `@server/*`

`apps/web` type-checks as two projects (`pnpm typecheck` runs both):
`tsconfig.server.json` (Node types) for `src/server`, and
`tsconfig.app.json` (DOM lib, no Node types) for browser code, which
resolves `@server/*` to the server project's emitted declarations.

Any file reachable from the browser program — including framework
entries pulled in by `routeTree.gen.ts`, like `start.ts` — must import
server modules through the `@server/*` alias, never a relative
`./server/*` path. A relative import bypasses the declaration remap and
drags the server source graph (and `@types/node` globals) back into the
browser program.

### Authentication is enforced by global middleware

Every TanStack Start server function is authenticated by default.
Expand Down
2 changes: 1 addition & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"develop": "vite",
"start": "node scripts/check-api.mjs && node .output/server/index.mjs",
"test": "TZ=UTC vitest",
"typecheck": "tsc --noEmit"
"typecheck": "tsc -p tsconfig.server.json && tsc -p tsconfig.app.json"
},
"dependencies": {
"@hookform/resolvers": "^5.4.0",
Expand Down
30 changes: 19 additions & 11 deletions apps/web/src/start.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,20 @@
import { randomBytes } from "node:crypto";
import {
sentryGlobalFunctionMiddleware,
sentryGlobalRequestMiddleware,
} from "@sentry/tanstackstart-react";
import {
createCsrfMiddleware,
createMiddleware,
createStart,
} from "@tanstack/react-start";

import {
createFirstUserFn,
loginFn,
logoutFn,
resetPasswordFn,
} from "./server/auth/functions";
import { createAuthenticationMiddleware } from "./server/auth/middleware";
import { errorLoggingMiddleware } from "./server/error-logging";
} from "@server/auth/functions";
import { createAuthenticationMiddleware } from "@server/auth/middleware";
import { errorLoggingMiddleware } from "@server/error-logging";
import {
createCsrfMiddleware,
createMiddleware,
createStart,
} from "@tanstack/react-start";

// logoutFn must be exempt so stale or missing cookies can still be cleared.
// createFirstUserFn runs before any user or session exists.
Expand Down Expand Up @@ -73,6 +71,16 @@ const documentHeaders: DocumentHeader[] = [
buildCacheControl,
];

// Per-request CSP nonce. Deliberately uses the Web Crypto and `btoa` globals
// rather than node:crypto/Buffer: this file is reachable from the browser
// program (routeTree.gen.ts imports it) and must type-check without Node types.
// Both globals exist in our Node runtime, so the server middleware is safe.
function generateNonce(): string {
return btoa(
String.fromCharCode(...crypto.getRandomValues(new Uint8Array(16))),
);
}

const documentHeadersMiddleware = createMiddleware().server(
async ({ next }) => {
const result = await next();
Expand All @@ -83,7 +91,7 @@ const documentHeadersMiddleware = createMiddleware().server(
}

const html = await response.text();
const nonce = randomBytes(16).toString("base64");
const nonce = generateNonce();
const body = html.replace(/<script(?=[\s>])/g, `<script nonce="${nonce}"`);
const headers = new Headers(response.headers);
for (const build of documentHeaders) {
Expand Down
9 changes: 6 additions & 3 deletions apps/web/src/uploads/uploader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,9 +101,12 @@ export function upload(file: File, fileType: UploadType) {
apiClient
.post("/uploads")
.query({ name: file.name, type: fileType })
// @ts-expect-error A browser File is a valid multipart value at runtime; superagent's
// Node-flavored types (Blob from node:buffer) reject the DOM File. Remove once browser
// code no longer resolves @types/node globals.
// @ts-expect-error A browser File is a valid multipart value at runtime, but
// superagent's `.attach` types are Node-flavored (Blob/Buffer/ReadStream) and its
// deps (form-data, undici-types) pull @types/node globals into the browser program
// via `/// <reference types="node" />`, so the DOM File is rejected. The browser/server
// tsconfig split does not remove this; it goes away when superagent is dropped in favour
// of server functions. Remove this directive then.
.attach("file", file)
.on("progress", onProgress)
.then(() => useUploaderStore.getState().removeUpload(localId))
Expand Down
37 changes: 37 additions & 0 deletions apps/web/tsconfig.app.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"types": [],
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"paths": {
"@/*": ["./src/*"],
"@account/*": ["./src/account/*"],
"@administration/*": ["./src/administration/*"],
"@analyses/*": ["./src/analyses/*"],
"@app/*": ["./src/app/*"],
"@base/*": ["./src/base/*"],
"@forms/*": ["./src/forms/*"],
"@groups/*": ["./src/groups/*"],
"@hmm/*": ["./src/hmm/*"],
"@indexes/*": ["./src/indexes/*"],
"@jobs/*": ["./src/jobs/*"],
"@banner/*": ["./src/banner/*"],
"@labels/*": ["./src/labels/*"],
"@nav/*": ["./src/nav/*"],
"@otus/*": ["./src/otus/*"],
"@quality/*": ["./src/quality/*"],
"@references/*": ["./src/references/*"],
"@samples/*": ["./src/samples/*"],
"@sequences/*": ["./src/sequences/*"],
"@server/*": ["./dist-types/server/*"],
"@subtraction/*": ["./src/subtraction/*"],
"@tasks/*": ["./src/tasks/*"],
"@tests/*": ["./src/tests/*"],
"@uploads/*": ["./src/uploads/*"],
"@users/*": ["./src/users/*"],
"@wall/*": ["./src/wall/*"]
}
},
"include": ["src/**/*"],
"exclude": ["src/server/**", "src/server.ts", "src/instrument.server.ts"]
}
23 changes: 23 additions & 0 deletions apps/web/tsconfig.server.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"declaration": true,
"emitDeclarationOnly": true,
"noEmit": false,
"outDir": "./dist-types",
"rootDir": "./src",
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"typeRoots": [
"../../node_modules/@types",
"node_modules/@types",
"./src/types"
],
"types": ["node"]
},
"include": [
"src/server/**/*",
"src/server.ts",
"src/start.ts",
"src/instrument.server.ts"
]
}
Loading