Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
65d3317
feat(source-control): GitCafe lives in @t3tools/source-control-gitcaf…
juliusmarminge Oct 10, 2026
500cb42
fix(web): add provider wizard no longer shifts sideways while it grow…
flamboh Oct 10, 2026
42f71df
fix(web): PR search keeps the caret where you type (#17675)
flamboh Oct 10, 2026
6d5ea19
fix(server): thread PR badges catch up when another environment reads…
flamboh Oct 10, 2026
bd2346e
fix(server): refuse editor paths with line breaks or quotes when the …
juliusmarminge Oct 10, 2026
c77a7b7
fix(web): align compact button touch targets (#17748)
Yash-Singh1 Oct 10, 2026
6266af3
fix(server): t3_thread_launch refuses a new worktree whose base ref h…
tris203 Oct 10, 2026
d6f3af7
fix(web): omit underlines on markdown image links (#17728)
Saikrishna1876 Oct 10, 2026
4606f9d
fix(server): restore auto resume for wrapped Claude gateway rate limi…
tzachbon Oct 10, 2026
e1ad595
fix(opencode): name OpenCode 2 sessions after their thread (#17414)
nkoynov Oct 10, 2026
472ea7e
fix(web): find update settings from the command palette (#17396)
sergical Oct 10, 2026
7b09ac7
fix(provider-opencode): tell OpenCode Zen and Go models apart (#17424)
mr-karan Oct 10, 2026
adbecda
fix(server): a pull that fast-forwards no longer fails on large Git o…
ScottN-PV Oct 10, 2026
91474e5
fix(web): cancel question auto-advance after navigation (#17364)
maxwellyoung Oct 10, 2026
33c1e36
fix(server): a bare repository name resolves to the signed-in account…
ScottN-PV Oct 10, 2026
71f6f1d
fix(web): a maximized right panel stays maximized when you return to …
jamesvillarrubia Oct 10, 2026
02612c2
fix(mobile): allow starting a task with only an image (#17409)
Claudesaul Oct 10, 2026
a418075
fix(server): PR watch no longer reports passed while a second run of …
ScottN-PV Oct 10, 2026
a5b4824
fix(server): say why a thread can't be settled (#17258)
DylanTX Oct 10, 2026
03b2105
fix(server): prevent busy terminals from starving history persistence…
StiensWout Oct 10, 2026
de49d63
feat(server): use macOS .icns app icons as project icons (#17149)
psv2522 Oct 10, 2026
a1dee8b
fix(mobile): usage reset icon lines up with its row (#17175)
Aforno Oct 10, 2026
ee7924f
fix(web): paths pasted after @ keep their underscores (#16619)
derektrimm Oct 10, 2026
dd4549e
fix(server): settle every OpenCode subagent call one report answers (…
nkoynov Oct 10, 2026
8bf0a38
perf(web): switching project keeps Diagnostics and Providers mounted …
flamboh Oct 10, 2026
f7664fd
perf(web): Open Source Licenses downloads its manifest once per sessi…
flamboh Oct 10, 2026
94d1a8e
fix(server): restore OpenCode adapter test typecheck (#17810)
Yash-Singh1 Oct 10, 2026
98beed1
fix(server): Claude subagents show the reasoning effort they run at (…
RakshithBhat03 Oct 10, 2026
8c777fb
feat(server): stop Claude subagents without stopping their owner (#17…
Yash-Singh1 Oct 10, 2026
afd98d7
fix(server): stopped native subagents no longer read as Running (#17223)
im-kvijay Oct 10, 2026
a11f464
perf(server): t3_thread_list reads only the listed project's threads …
only21mil Oct 10, 2026
cfbd145
fix(server): show diffs for projects outside the server cwd (#17724)
maria-rcks Oct 10, 2026
50647de
fix(server): check the specific scope for scripts, preview input, and…
juliusmarminge Oct 10, 2026
dacd2cb
fix(source-control): stop Forgejo status refresh from scanning every …
loispostula Oct 10, 2026
fd3549c
refactor(contracts): source control provider kind is an open branded …
juliusmarminge Oct 10, 2026
7bc7328
feat(source-control): each host package ships a client definition (#1…
juliusmarminge Oct 10, 2026
c8a6033
refactor(client-runtime): add project clone sources come from host de…
juliusmarminge Oct 10, 2026
579159d
refactor(web): host presentation and behavior come from client defini…
juliusmarminge Oct 10, 2026
b0249ac
refactor(source-control): reference parsing and project matching are …
juliusmarminge Oct 10, 2026
9b40d37
feat(pull-requests): quick actions follow each host's capabilities, n…
juliusmarminge Oct 10, 2026
a4c8745
feat(projects): new projects can be published to any ready host (#17860)
juliusmarminge Oct 10, 2026
f82796a
fix(server): send Claude MCP servers over the control channel (#17898)
juliusmarminge Oct 10, 2026
ec43786
fix(web): a finished reply replaced by a steer is no longer labeled p…
juliusmarminge Oct 10, 2026
61eb4af
fix(client-runtime): queued runs that start after a steer show up in …
juliusmarminge Oct 10, 2026
2a4ee33
feat(mobile): choose the microphone order for voice input (#17896)
juliusmarminge Oct 10, 2026
4f63275
feat(source-control): host settings live on each host's definition, w…
juliusmarminge Oct 10, 2026
38c187b
fix(pi): preserve tool images and structured results (#17851)
StiensWout Oct 10, 2026
a8bdfdb
fix(server): Claude 5 task lists reach the tasks drawer (#14964)
Mnigos Oct 10, 2026
f92afe7
fix(web): find bar and thread details panel stop covering each other …
MatthewFeroz Oct 10, 2026
b307b0b
chore(sync): import upstream changes and keep desktop removed
Igloczek Oct 11, 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/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
"@t3tools/source-control-bitbucket": "workspace:*",
"@t3tools/source-control-core": "workspace:*",
"@t3tools/source-control-forgejo": "workspace:*",
"@t3tools/source-control-gitcafe": "workspace:*",
"@t3tools/source-control-github": "workspace:*",
"@t3tools/source-control-gitlab": "workspace:*",
"effect": "catalog:",
Expand Down
32 changes: 32 additions & 0 deletions apps/server/src/assets/AssetAccess.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1226,6 +1226,38 @@ describe("AssetAccess", () => {
}).pipe(Effect.provide(layerTest)),
);

it.effect("serves a native macOS app icon as its embedded PNG", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const root = yield* fileSystem.makeTempDirectoryScoped({
prefix: "t3-asset-favicon-icns-",
});
const icns = new Uint8Array(8 + 8 + screenshotPng.length);
icns.set(new TextEncoder().encode("icns"));
new DataView(icns.buffer).setUint32(4, icns.length);
icns.set(new TextEncoder().encode("ic07"), 8);
new DataView(icns.buffer).setUint32(12, 8 + screenshotPng.length);
icns.set(screenshotPng, 16);
yield* fileSystem.makeDirectory(path.join(root, "Resources"));
yield* fileSystem.writeFile(path.join(root, "Resources", "AppIcon.icns"), icns);

const result = yield* issueAssetUrl({
resource: { _tag: "project-favicon", cwd: root },
});
const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
const separatorIndex = suffix.indexOf("/");

expect(result.sourcePath).toBe(path.join("Resources", "AppIcon.icns"));
const asset = yield* resolveAsset(
suffix.slice(0, separatorIndex),
suffix.slice(separatorIndex + 1),
);
expect(asset?.kind === "bytes" ? asset.mimeType : null).toBe("image/png");
expect(asset?.kind === "bytes" ? Uint8Array.from(asset.bytes) : null).toEqual(screenshotPng);
}).pipe(Effect.provide(layerTest)),
);

it.effect("rejects a resolved project favicon with a non-image extension", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
Expand Down
36 changes: 30 additions & 6 deletions apps/server/src/assets/AssetAccess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,10 @@ import {
type ImageDimensions,
} from "@t3tools/shared/imageDimensions";
import { githubMediaFetchUrl, githubMediaFileName } from "@t3tools/shared/githubMedia";
import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon";
import {
isProjectFaviconPath,
PROJECT_FAVICON_FALLBACK_MARKER,
} from "@t3tools/shared/projectFavicon";
import { MAX_TOOL_OUTPUT_IMAGE_BASE64_LENGTH, toolOutputImages } from "@t3tools/shared/toolOutput";
import * as Clock from "effect/Clock";
import * as Crypto from "effect/Crypto";
Expand All @@ -57,6 +60,7 @@ import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion";
import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts";
import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
import * as NativeAppIconResolver from "./NativeAppIconResolver.ts";
import { extractIcnsPng } from "./icns.ts";
import { openMediaFile, readMediaFileHeader, type OpenMediaFile } from "./MediaFile.ts";
import * as HostProcess from "@t3tools/shared/HostProcess";

Expand All @@ -66,6 +70,8 @@ const SIGNING_SECRET_NAME = "asset-access-signing-key";
const ASSET_TOKEN_TTL_MS = 60 * 60 * 1000;
const PROJECT_FAVICON_TOKEN_BUCKET_MS = 30 * 60 * 1000;
const PROJECT_FAVICON_VERSION_PREFIX = "v";
// Clients draw project icons from a 96px thumbnail at most.
const PROJECT_FAVICON_ICNS_MIN_SIZE = 96;
const INLINE_VIDEO_MIME_TYPE_PATTERN = /^video\/[\w!#$&^.+-]+$/i;
// Extensions a document viewer or audio player may request inline. The extension comes from
// the attachment id the server assigned, never from the client's mime type.
Expand Down Expand Up @@ -234,6 +240,26 @@ const readToolOutputImage = Effect.fn("AssetAccess.readToolOutputImage")(functio
: { mimeType: image.mimeType, bytes: Buffer.from(image.data, "base64") };
});

/** Serves an `.icns` favicon as its embedded PNG, since browsers cannot draw it. */
const projectFaviconAsset = Effect.fn("AssetAccess.projectFaviconAsset")(function* (
filePath: string,
) {
if (!filePath.toLowerCase().endsWith(".icns")) {
return { kind: "file", path: filePath } satisfies ResolvedAsset;
}
const fileSystem = yield* FileSystem.FileSystem;
const bytes = yield* fileSystem.readFile(filePath).pipe(
Effect.tapError((cause) =>
Effect.logError("Failed to read project icon image.", { filePath, cause }),
),
Effect.orElseSucceed(() => null),
);
const png = bytes ? extractIcnsPng(bytes, PROJECT_FAVICON_ICNS_MIN_SIZE) : null;
return png
? ({ kind: "bytes", bytes: png, mimeType: "image/png" } satisfies ResolvedAsset)
: null;
});

const resolveCanonicalFile = Effect.fn("AssetAccess.resolveCanonicalFile")(function* (
filePath: string,
) {
Expand Down Expand Up @@ -634,7 +660,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
const relativePath =
faviconPath && !isExternalOverride ? path.relative(workspaceRoot, faviconPath) : null;
const sourceFaviconPath = isExternalOverride ? faviconPath : relativePath;
if (sourceFaviconPath && !isWorkspaceImagePreviewPath(sourceFaviconPath)) {
if (sourceFaviconPath && !isProjectFaviconPath(sourceFaviconPath)) {
return yield* new AssetPreviewTypeValidationError({ resource: input.resource });
}
sourcePath = sourceFaviconPath ?? undefined;
Expand Down Expand Up @@ -837,7 +863,7 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* (
workspaceRoot: claims.workspaceRoot,
relativePath: claims.relativePath,
});
return faviconPath ? ({ kind: "file", path: faviconPath } satisfies ResolvedAsset) : null;
return faviconPath ? yield* projectFaviconAsset(faviconPath) : null;
}

if (claims.kind === "project-favicon-external") {
Expand All @@ -850,9 +876,7 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* (
),
Effect.orElseSucceed(() => null),
);
return faviconPath === claims.filePath
? ({ kind: "file", path: faviconPath } satisfies ResolvedAsset)
: null;
return faviconPath === claims.filePath ? yield* projectFaviconAsset(faviconPath) : null;
}

if (claims.kind === "github-media") {
Expand Down
62 changes: 62 additions & 0 deletions apps/server/src/assets/icns.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { describe, expect, it } from "vite-plus/test";

import { extractIcnsPng } from "./icns.ts";

function pngHeader(size: number): Uint8Array {
const png = new Uint8Array(24);
png.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13, 0x49, 0x48, 0x44, 0x52]);
new DataView(png.buffer).setUint32(16, size);
new DataView(png.buffer).setUint32(20, size);
return png;
}

function icns(...entries: ReadonlyArray<readonly [type: string, data: Uint8Array]>): Uint8Array {
const length = 8 + entries.reduce((total, [, data]) => total + 8 + data.length, 0);
const bytes = new Uint8Array(length);
const view = new DataView(bytes.buffer);
bytes.set(new TextEncoder().encode("icns"));
view.setUint32(4, length);
let offset = 8;
for (const [type, data] of entries) {
bytes.set(new TextEncoder().encode(type), offset);
view.setUint32(offset + 4, 8 + data.length);
bytes.set(data, offset + 8);
offset += 8 + data.length;
}
return bytes;
}

const widthOf = (png: Uint8Array | null) =>
png ? new DataView(png.buffer, png.byteOffset).getUint32(16) : null;

describe("extractIcnsPng", () => {
it("picks the smallest PNG that covers the requested size", () => {
const file = icns(
["TOC ", new Uint8Array(16)],
["ic09", pngHeader(512)],
["icp5", pngHeader(32)],
["ic07", pngHeader(128)],
["ic08", pngHeader(256)],
);

expect(widthOf(extractIcnsPng(file, 96))).toBe(128);
});

it("falls back to the largest PNG when none covers the requested size", () => {
const file = icns(["icp4", pngHeader(16)], ["icp5", pngHeader(32)]);

expect(widthOf(extractIcnsPng(file, 96))).toBe(32);
});

it("returns null for files without a PNG entry or with a foreign header", () => {
expect(extractIcnsPng(icns(["it32", new Uint8Array(64)]), 96)).toBeNull();
expect(extractIcnsPng(pngHeader(128), 96)).toBeNull();
});

it("stops at an entry whose length runs past the file", () => {
const file = icns(["ic07", pngHeader(128)], ["ic08", pngHeader(256)]);
new DataView(file.buffer).setUint32(8 + 8 + 24 + 4, 4096);

expect(widthOf(extractIcnsPng(file, 200))).toBe(128);
});
});
30 changes: 30 additions & 0 deletions apps/server/src/assets/icns.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { readImageDimensions } from "@t3tools/shared/imageDimensions";

const ICNS_HEADER_BYTES = 8;
const ICNS_MAGIC = 0x69636e73; // "icns"
const PNG_MAGIC = 0x89504e47; // "\x89PNG"

/**
* Picks the smallest PNG at least `minSize` wide, else the largest; null when there is none.
* Returns a copy so the response does not pin the whole `.icns` buffer.
*/
export function extractIcnsPng(bytes: Uint8Array, minSize: number): Uint8Array | null {
const data = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
if (bytes.length < ICNS_HEADER_BYTES || data.getUint32(0) !== ICNS_MAGIC) return null;
const end = Math.min(data.getUint32(4), bytes.length);
let best: { readonly png: Uint8Array; readonly width: number } | null = null;
for (let offset = ICNS_HEADER_BYTES; offset + ICNS_HEADER_BYTES <= end;) {
const length = data.getUint32(offset + 4);
if (length < ICNS_HEADER_BYTES || offset + length > end) break;
const entry = bytes.subarray(offset + ICNS_HEADER_BYTES, offset + length);
const isPng = entry.length >= 4 && data.getUint32(offset + ICNS_HEADER_BYTES) === PNG_MAGIC;
offset += length;
const width = isPng ? readImageDimensions(entry)?.width : undefined;
if (width === undefined) continue;
const isBetter =
best === null ||
(best.width < minSize ? width > best.width : width >= minSize && width < best.width);
if (isBetter) best = { png: entry, width };
}
return best?.png.slice() ?? null;
}
16 changes: 14 additions & 2 deletions apps/server/src/auth/EnvironmentAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import {
AuthAdministrativeScopes,
AuthOrchestrationOperateScope,
AuthOrchestrationReadScope,
AuthSettingsWriteScope,
AuthSourceControlWriteScope,
AuthStandardClientScopes,
type AuthAccessTokenResult,
type AuthBrowserSessionResult,
Expand Down Expand Up @@ -74,7 +76,10 @@ export interface IssuedBearerSession {
* token cannot reach the full RPC surface around that cap.
*
* A read-only grant holds `orchestration:read` alone. Any other grant also
* holds `orchestration:operate` and carries its runtime-mode ceiling.
* holds `orchestration:operate` and carries its runtime-mode ceiling. A
* full-access client may also change environment preferences and clone
* repositories, so it holds the scopes those need over RPC. Approving a grant
* requires every scope it holds.
*/
const MCP_CLIENT_SUBJECT = "mcp-client";
const MCP_CLIENT_SESSION_TTL = Duration.days(30);
Expand All @@ -84,7 +89,14 @@ export const mcpClientScopes = (
): ReadonlyArray<AuthEnvironmentScope> =>
access === "read-only"
? [AuthOrchestrationReadScope]
: [AuthOrchestrationReadScope, AuthOrchestrationOperateScope];
: access === "full-access"
? [
AuthOrchestrationReadScope,
AuthOrchestrationOperateScope,
AuthSettingsWriteScope,
AuthSourceControlWriteScope,
]
: [AuthOrchestrationReadScope, AuthOrchestrationOperateScope];

export interface McpClientSession {
readonly sessionId: AuthSessionId;
Expand Down
9 changes: 9 additions & 0 deletions apps/server/src/auth/McpOAuth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -451,6 +451,15 @@ it.live(
const readOnlyResponse = yield* approveWith(readOnly.credential);
expect(readOnlyResponse.status).toBe(400);
expect(readOnlyResponse.message).toContain("cannot grant this access");
const operator = yield* auth.issuePairingCredential({
scopes: ["orchestration:read", "orchestration:operate"],
});
const operatorFull = yield* decide(handler, params, {
_tag: "pairing-code",
access: "full-access",
code: operator.credential,
});
expect(operatorFull.status).toBe(400);

// A read-only code can approve read-only access.
const readOnlyCode = yield* auth.issuePairingCredential({ scopes: ["orchestration:read"] });
Expand Down
8 changes: 8 additions & 0 deletions apps/server/src/auth/RpcAuthorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import {
ServerSettingsPatch,
ProviderInstanceMutation,
requiredScopesForServerSettingsPatch,
requiredScopesForProjectMutation,
ProjectMutation,
AuthSettingsWriteScope,
AuthProvidersManageScope,
AuthEnvironmentMaintainScope,
Expand Down Expand Up @@ -137,6 +139,7 @@ export const RPC_REQUIRED_SCOPES = {
// Read scope like the reads it un-caches: refreshing is part of reading, and a read-only
// client pressing refresh must not be told it may not look again.
[WS_METHODS.pullRequestsInvalidate]: AuthOrchestrationReadScope,
[WS_METHODS.pullRequestsReportState]: AuthOrchestrationReadScope,
[WS_METHODS.pullRequestsSubscribeRefreshes]: AuthOrchestrationReadScope,
// The candidate list is a read like the detail beside it; asking somebody for a review is a
// write like every other one.
Expand Down Expand Up @@ -243,6 +246,8 @@ const requiredScopesForSettingsUpdate = (payload: unknown) => {
: [...new Set([...scopes, AuthProvidersManageScope])];
};

const decodeProjectMutation = Schema.decodeUnknownSync(ProjectMutation);

const requiredScopesForRpcCall = (
method: string,
payload: unknown,
Expand All @@ -261,6 +266,9 @@ const requiredScopesForRpcCall = (
];
}
if (method === WS_METHODS.serverUpdateSettings) return requiredScopesForSettingsUpdate(payload);
if (method === WS_METHODS.projectsMutate) {
return requiredScopesForProjectMutation(decodeProjectMutation(payload));
}
const guarded = clientRpcRequiredScopes(method, payload);
if (guarded.length > 0) return guarded;
return [requiredScopeForRpcMethod(method)];
Expand Down
20 changes: 15 additions & 5 deletions apps/server/src/git/GitManager.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { SourceControlProviderKind } from "@t3tools/contracts";
// @effect-diagnostics nodeBuiltinImport:off
import * as NodeFS from "node:fs";
import * as NodePath from "node:path";
Expand Down Expand Up @@ -410,7 +411,13 @@ function createGitHubProviderWithFakeGh(scenario: FakeGhScenario = {}): {
const ghCalls: string[] = [];

const fail = (cwd: string, detail: string, cause?: unknown, operation = "fakeGh") =>
new SourceControlProviderFailure({ provider: "github", operation, cwd, detail, cause });
new SourceControlProviderFailure({
provider: SourceControlProviderKind.make("github"),
operation,
cwd,
detail,
cause,
});

/** `operation` is the provider method the call answers, which a failure reports. */
const execute = (input: {
Expand Down Expand Up @@ -534,7 +541,7 @@ function createGitHubProviderWithFakeGh(scenario: FakeGhScenario = {}): {
/** What GitHubSourceControlProvider makes of a pull request it read. */
const toChangeRequest = (summary: FakePullRequestSummary): ChangeRequest => ({
...summary,
provider: "github",
provider: SourceControlProviderKind.make("github"),
state: summary.state ?? "open",
closedAt: summary.closedAt ?? null,
mergedAt: summary.mergedAt ?? null,
Expand All @@ -546,7 +553,7 @@ function createGitHubProviderWithFakeGh(scenario: FakeGhScenario = {}): {

return {
service: {
kind: "github",
kind: SourceControlProviderKind.make("github"),
// The GitHub provider's own lookup rule and template convention, which GitManager reads
// instead of the kind.
headBranchProbe: ({ headSelectors }) => ({
Expand Down Expand Up @@ -602,7 +609,10 @@ function createGitHubProviderWithFakeGh(scenario: FakeGhScenario = {}): {
if (raw.length === 0) return [];
const decoded = decodeGitHubPullRequestListJson(raw);
return Result.isSuccess(decoded)
? decoded.success.map((record) => ({ provider: "github" as const, ...record }))
? decoded.success.map((record) => ({
provider: SourceControlProviderKind.make("github"),
...record,
}))
: [];
}),
),
Expand Down Expand Up @@ -2841,7 +2851,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {
operation: "lookupStatusPr",
branch: "feature/status-rate-limited",
errorTag: "SourceControlProviderError",
provider: "github",
provider: SourceControlProviderKind.make("github"),
providerOperation: "listChangeRequests",
errorDetail: "GitHub API rate limit exceeded. Requests resume when the limit resets.",
});
Expand Down
Loading
Loading