Skip to content
This repository was archived by the owner on Oct 10, 2026. It is now read-only.
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
1 change: 1 addition & 0 deletions apps/marketing/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"typecheck": "astro check"
},
"dependencies": {
"@t3tools/shared": "workspace:*",
"astro": "^7.0.3"
},
"devDependencies": {
Expand Down
10 changes: 10 additions & 0 deletions apps/marketing/src/pages/schema/t3.json.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import type { APIRoute } from "astro";

import { buildT3ProjectFileJsonSchema } from "@t3tools/shared/t3ProjectFile";

// Rendered at build time; published at https://t3.codes/schema/t3.json so
// t3.json files can reference it via "$schema" for editor/LSP support.
export const GET: APIRoute = () =>
new Response(`${JSON.stringify(buildT3ProjectFileJsonSchema(), null, 2)}\n`, {
headers: { "Content-Type": "application/json" },
});
2 changes: 1 addition & 1 deletion apps/marketing/vercel.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { VercelConfig } from "@vercel/config/v1";

export const config: VercelConfig = {
installCommand: "npm install -g vite-plus && vp install --filter '@t3tools/marketing'",
installCommand: "npm install -g vite-plus && vp install --filter '@t3tools/marketing...'",
buildCommand: "vp run --filter @t3tools/marketing build",
outputDirectory: "dist",
};
5 changes: 4 additions & 1 deletion apps/mobile/src/features/threads/NewTaskDraftScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -582,10 +582,13 @@ export function NewTaskDraftScreen(props: {
? "Approve actions"
: flow.runtimeMode === "auto-accept-edits"
? "Auto-accept edits"
: "Full access",
: flow.runtimeMode === "auto"
? "Auto"
: "Full access",
subactions: [
{ id: "options:runtime:approval-required", title: "Approve actions" },
{ id: "options:runtime:auto-accept-edits", title: "Auto-accept edits" },
{ id: "options:runtime:auto", title: "Auto" },
{ id: "options:runtime:full-access", title: "Full access" },
].map((option) => {
const value = option.id.replace("options:runtime:", "");
Expand Down
5 changes: 4 additions & 1 deletion apps/mobile/src/features/threads/ThreadComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -640,10 +640,13 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
? "Approve actions"
: currentRuntimeMode === "auto-accept-edits"
? "Auto-accept edits"
: "Full access",
: currentRuntimeMode === "auto"
? "Auto"
: "Full access",
subactions: [
{ id: "options:runtime:approval-required", title: "Approve actions" },
{ id: "options:runtime:auto-accept-edits", title: "Auto-accept edits" },
{ id: "options:runtime:auto", title: "Auto" },
{ id: "options:runtime:full-access", title: "Full access" },
].map((option) => {
const value = option.id.replace("options:runtime:", "");
Expand Down
6 changes: 5 additions & 1 deletion apps/server/src/assets/AssetAccess.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import * as PlatformError from "effect/PlatformError";
import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
import * as ServerConfig from "../config.ts";
import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts";
import * as T3ProjectFileLoader from "../project/T3ProjectFileLoader.ts";
import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
import { ASSET_ROUTE_PREFIX, issueAssetUrl, resolveAsset } from "./AssetAccess.ts";

Expand All @@ -20,7 +21,10 @@ const configLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), {
const testLayer = Layer.mergeAll(
configLayer,
WorkspacePaths.layer,
ProjectFaviconResolver.layer.pipe(Layer.provide(WorkspacePaths.layer)),
ProjectFaviconResolver.layer.pipe(
Layer.provide(WorkspacePaths.layer),
Layer.provide(T3ProjectFileLoader.layer),
),
ServerSecretStore.layer.pipe(Layer.provide(configLayer)),
).pipe(Layer.provideMerge(NodeServices.layer));

Expand Down
67 changes: 65 additions & 2 deletions apps/server/src/project/ProjectFaviconResolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,15 @@ import * as PlatformError from "effect/PlatformError";

import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
import * as ProjectFaviconResolver from "./ProjectFaviconResolver.ts";
import * as T3ProjectFileLoader from "./T3ProjectFileLoader.ts";

const TestLayer = Layer.empty.pipe(
Layer.provideMerge(ProjectFaviconResolver.layer.pipe(Layer.provide(WorkspacePaths.layer))),
Layer.provideMerge(
ProjectFaviconResolver.layer.pipe(
Layer.provide(WorkspacePaths.layer),
Layer.provide(T3ProjectFileLoader.layer),
),
),
Layer.provideMerge(NodeServices.layer),
);

Expand All @@ -37,7 +43,7 @@ const writeTextFile = Effect.fn("writeTextFile")(function* (

const makeResolverWithFileSystem = (fileSystem: FileSystem.FileSystem) =>
ProjectFaviconResolver.make.pipe(
Effect.provide(WorkspacePaths.layer),
Effect.provide([WorkspacePaths.layer, T3ProjectFileLoader.layer]),
Effect.provideService(FileSystem.FileSystem, fileSystem),
);

Expand All @@ -56,6 +62,63 @@ it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => {
}),
);

it.effect("prefers a t3.json iconPath over well-known files", () =>
Effect.gen(function* () {
const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
const cwd = yield* makeTempDir;
yield* writeTextFile(cwd, "t3.json", '{ "iconPath": "brand/mark.svg" }');
yield* writeTextFile(cwd, "brand/mark.svg", "<svg>mark</svg>");
yield* writeTextFile(cwd, "favicon.svg", "<svg>favicon</svg>");

const resolved = yield* resolver.resolvePath(cwd);

expect(resolved).not.toBeNull();
expect(resolved).toContain("brand/mark.svg");
}),
);

it.effect("falls back to well-known files when the t3.json iconPath does not exist", () =>
Effect.gen(function* () {
const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
const cwd = yield* makeTempDir;
yield* writeTextFile(cwd, "t3.json", '{ "iconPath": "brand/missing.svg" }');
yield* writeTextFile(cwd, "favicon.svg", "<svg>favicon</svg>");

const resolved = yield* resolver.resolvePath(cwd);

expect(resolved).not.toBeNull();
expect(resolved).toContain("favicon.svg");
}),
);

it.effect("ignores invalid t3.json files", () =>
Effect.gen(function* () {
const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
const cwd = yield* makeTempDir;
yield* writeTextFile(cwd, "t3.json", "{ not json");
yield* writeTextFile(cwd, "favicon.svg", "<svg>favicon</svg>");

const resolved = yield* resolver.resolvePath(cwd);

expect(resolved).not.toBeNull();
expect(resolved).toContain("favicon.svg");
}),
);

it.effect("does not resolve a t3.json iconPath outside the workspace root", () =>
Effect.gen(function* () {
const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
const parent = yield* makeTempDir;
const cwd = `${parent}/app`;
yield* writeTextFile(parent, "secret.svg", "<svg>secret</svg>");
yield* writeTextFile(cwd, "t3.json", '{ "iconPath": "../secret.svg" }');

const resolved = yield* resolver.resolvePath(cwd);

expect(resolved).toBeNull();
}),
);

it.effect("resolves icon hrefs from project source files", () =>
Effect.gen(function* () {
const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
Expand Down
11 changes: 11 additions & 0 deletions apps/server/src/project/ProjectFaviconResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import * as PlatformError from "effect/PlatformError";
import * as Schema from "effect/Schema";

import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
import * as T3ProjectFileLoader from "./T3ProjectFileLoader.ts";

// Well-known favicon paths checked in order.
const FAVICON_CANDIDATES = [
Expand Down Expand Up @@ -117,6 +118,7 @@ export const make = Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const workspacePaths = yield* WorkspacePaths.WorkspacePaths;
const projectFileLoader = yield* T3ProjectFileLoader.T3ProjectFileLoader;

const resolveIconHref = (href: string): ReadonlyArray<string> => {
const clean = href.replace(/^\//, "");
Expand Down Expand Up @@ -197,6 +199,15 @@ export const make = Effect.gen(function* () {
}),
),
);
// A t3.json iconPath takes precedence over the well-known locations.
const projectFile = yield* projectFileLoader.load(projectCwd);
if (Option.isSome(projectFile) && projectFile.value.iconPath !== undefined) {
const existing = yield* findExistingFile(projectCwd, [projectFile.value.iconPath]);
if (existing) {
return existing;
}
}

for (const candidate of FAVICON_CANDIDATES) {
const existing = yield* findExistingFile(projectCwd, [candidate]);
if (existing) {
Expand Down
89 changes: 89 additions & 0 deletions apps/server/src/project/T3ProjectFileLoader.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import * as NodeServices from "@effect/platform-node/NodeServices";
import { it, describe, expect } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Path from "effect/Path";

import * as T3ProjectFileLoader from "./T3ProjectFileLoader.ts";

const TestLayer = Layer.empty.pipe(
Layer.provideMerge(T3ProjectFileLoader.layer),
Layer.provideMerge(NodeServices.layer),
);

const makeTempDir = Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
return yield* fileSystem.makeTempDirectoryScoped({
prefix: "t3code-project-file-",
});
});

const writeProjectFile = Effect.fn("writeProjectFile")(function* (cwd: string, contents: string) {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
yield* fileSystem.writeFileString(path.join(cwd, "t3.json"), contents).pipe(Effect.orDie);
});

it.layer(TestLayer)("T3ProjectFileLoader", (it) => {
describe("load", () => {
it.effect("loads and decodes a valid t3.json", () =>
Effect.gen(function* () {
const loader = yield* T3ProjectFileLoader.T3ProjectFileLoader;
const cwd = yield* makeTempDir;
yield* writeProjectFile(
cwd,
`{
// JSONC is tolerated
"iconPath": "assets/logo.svg",
"scripts": [{ "name": "Dev", "command": "pnpm dev" }],
}`,
);

const loaded = yield* loader.load(cwd);

expect(Option.isSome(loaded)).toBe(true);
if (Option.isSome(loaded)) {
expect(loaded.value.iconPath).toBe("assets/logo.svg");
expect(loaded.value.scripts).toEqual([{ name: "Dev", command: "pnpm dev" }]);
}
}),
);

it.effect("returns none when t3.json is missing", () =>
Effect.gen(function* () {
const loader = yield* T3ProjectFileLoader.T3ProjectFileLoader;
const cwd = yield* makeTempDir;

const loaded = yield* loader.load(cwd);

expect(Option.isNone(loaded)).toBe(true);
}),
);

it.effect("returns none for malformed JSON without failing", () =>
Effect.gen(function* () {
const loader = yield* T3ProjectFileLoader.T3ProjectFileLoader;
const cwd = yield* makeTempDir;
yield* writeProjectFile(cwd, "{ not json");

const loaded = yield* loader.load(cwd);

expect(Option.isNone(loaded)).toBe(true);
}),
);

it.effect("returns none for schema-invalid files without failing", () =>
Effect.gen(function* () {
const loader = yield* T3ProjectFileLoader.T3ProjectFileLoader;
const cwd = yield* makeTempDir;
yield* writeProjectFile(cwd, '{ "scripts": [{ "name": "Dev" }] }');

const loaded = yield* loader.load(cwd);

expect(Option.isNone(loaded)).toBe(true);
}),
);
});
});
108 changes: 108 additions & 0 deletions apps/server/src/project/T3ProjectFileLoader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/**
* T3ProjectFileLoader - Effect service that loads the checked-in `t3.json`
* project file from a workspace root.
*
* Loading is best-effort: a missing file resolves to `Option.none`, and
* unreadable or invalid files are logged and treated as absent so callers
* can fall back to their defaults.
*
* @module T3ProjectFileLoader
*/
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Path from "effect/Path";
import * as Schema from "effect/Schema";

import { T3_PROJECT_FILE_NAME, type T3ProjectFile } from "@t3tools/contracts";
import { T3ProjectFileFromJson } from "@t3tools/shared/t3ProjectFile";

const decodeT3ProjectFileJson = Schema.decodeEffect(T3ProjectFileFromJson);

export class T3ProjectFileLoadError extends Schema.TaggedErrorClass<T3ProjectFileLoadError>()(
"T3ProjectFileLoadError",
{
operation: Schema.Literals(["read", "decode"]),
workspaceRoot: Schema.String,
filePath: Schema.String,
cause: Schema.Defect(),
},
) {
override get message(): string {
return `Failed to ${this.operation} ${T3_PROJECT_FILE_NAME} at ${this.filePath}.`;
}
}

/** Service tag for t3.json project file loading. */
export class T3ProjectFileLoader extends Context.Service<
T3ProjectFileLoader,
{
/**
* Load and decode `t3.json` at the workspace root.
*
* Never fails: missing, unreadable, or invalid files resolve to
* `Option.none` (invalid files are logged as warnings).
*/
readonly load: (workspaceRoot: string) => Effect.Effect<Option.Option<T3ProjectFile>>;
}
>()("t3/project/T3ProjectFileLoader") {}

const logT3ProjectFileLoadError = (error: T3ProjectFileLoadError) =>
Effect.logWarning(error).pipe(
Effect.annotateLogs({
operation: error.operation,
workspaceRoot: error.workspaceRoot,
filePath: error.filePath,
errorTag: error._tag,
}),
);

export const make = Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;

const load: T3ProjectFileLoader["Service"]["load"] = Effect.fn("T3ProjectFileLoader.load")(
function* (workspaceRoot) {
const filePath = path.join(workspaceRoot, T3_PROJECT_FILE_NAME);
const raw = yield* fileSystem.readFileString(filePath).pipe(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Bound t3.json reads before loading the whole file

Every favicon resolution now calls this loader before checking the normal icon candidates, and readFileString allocates the entire t3.json before schema validation. A project containing an accidentally generated or maliciously large file can therefore cause large repeated allocations or exhaust the server when its favicon is requested; apply a small size limit before reading, consistent with the existing bounded workspace-file reader.

Useful? React with 👍 / 👎.

Effect.map(Option.some),
Effect.catchTags({
PlatformError: (error) =>
error.reason._tag === "NotFound"
? Effect.succeed(Option.none<string>())
: logT3ProjectFileLoadError(
new T3ProjectFileLoadError({
operation: "read",
workspaceRoot,
filePath,
cause: error,
}),
).pipe(Effect.as(Option.none<string>())),
}),
);
if (Option.isNone(raw)) {
return Option.none<T3ProjectFile>();
}
return yield* decodeT3ProjectFileJson(raw.value).pipe(
Effect.map(Option.some),
Effect.catchTags({
SchemaError: (error) =>
logT3ProjectFileLoadError(
new T3ProjectFileLoadError({
operation: "decode",
workspaceRoot,
filePath,
cause: error,
}),
).pipe(Effect.as(Option.none<T3ProjectFile>())),
}),
);
},
);

return T3ProjectFileLoader.of({ load });
});

export const layer = Layer.effect(T3ProjectFileLoader, make);
Loading
Loading