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
114 changes: 114 additions & 0 deletions apps/discord-bot/src/t3/PersistedBearer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
// @effect-diagnostics nodeBuiltinImport:off globalDate:off
import * as NodeFS from "node:fs";
import * as NodeOS from "node:os";
import * as NodePath from "node:path";
import { describe, expect, it } from "vite-plus/test";

import {
clearPersistedBearerSession,
parsePersistedBearerSession,
persistedBearerExpiresAtIso,
readPersistedBearerSession,
shouldReusePersistedBearer,
writePersistedBearerSession,
} from "./PersistedBearer.ts";

describe("shouldReusePersistedBearer", () => {
const nowMs = Date.parse("2026-08-20T06:00:00.000Z");
const record = {
accessToken: "tok",
expiresAt: "2026-09-19T06:00:00.000Z",
httpBaseUrl: "http://127.0.0.1:3773/",
};

it("reuses a same-host token with more than an hour remaining", () => {
expect(
shouldReusePersistedBearer({
record,
nowMs,
httpBaseUrl: "http://127.0.0.1:3773",
}),
).toBe(true);
});

it("rejects a token for a different T3 host", () => {
expect(
shouldReusePersistedBearer({
record,
nowMs,
httpBaseUrl: "http://127.0.0.1:9999",
}),
).toBe(false);
});

it("rejects a token inside the remaining-time floor", () => {
expect(
shouldReusePersistedBearer({
record: { ...record, expiresAt: "2026-08-20T06:30:00.000Z" },
nowMs,
httpBaseUrl: record.httpBaseUrl,
}),
).toBe(false);
});

it("rejects an empty token", () => {
expect(
shouldReusePersistedBearer({
record: { ...record, accessToken: " " },
nowMs,
httpBaseUrl: record.httpBaseUrl,
}),
).toBe(false);
});
});

describe("parsePersistedBearerSession", () => {
it("accepts a well-formed document", () => {
expect(
parsePersistedBearerSession({
accessToken: "tok",
expiresAt: "2026-09-19T06:00:00.000Z",
httpBaseUrl: "http://127.0.0.1:3773/",
}),
).toEqual({
accessToken: "tok",
expiresAt: "2026-09-19T06:00:00.000Z",
httpBaseUrl: "http://127.0.0.1:3773/",
});
});

it("rejects missing fields and bad expiry", () => {
expect(parsePersistedBearerSession({ accessToken: "tok" })).toBeNull();
expect(
parsePersistedBearerSession({
accessToken: "tok",
expiresAt: "not-a-date",
httpBaseUrl: "http://127.0.0.1:3773/",
}),
).toBeNull();
expect(parsePersistedBearerSession("tok")).toBeNull();
});
});

describe("persisted bearer file", () => {
it("round-trips and clears a session on disk", () => {
const dataDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-discord-bearer-"));
try {
expect(readPersistedBearerSession(dataDir)).toBeNull();
writePersistedBearerSession(dataDir, {
accessToken: "tok-1",
expiresAt: persistedBearerExpiresAtIso(Date.parse("2026-08-20T06:00:00.000Z"), 3600),
httpBaseUrl: "http://127.0.0.1:3773/",
});
expect(readPersistedBearerSession(dataDir)).toEqual({
accessToken: "tok-1",
expiresAt: "2026-08-20T07:00:00.000Z",
httpBaseUrl: "http://127.0.0.1:3773/",
});
clearPersistedBearerSession(dataDir);
expect(readPersistedBearerSession(dataDir)).toBeNull();
} finally {
NodeFS.rmSync(dataDir, { recursive: true, force: true });
}
});
});
97 changes: 97 additions & 0 deletions apps/discord-bot/src/t3/PersistedBearer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
// @effect-diagnostics nodeBuiltinImport:off preferSchemaOverJson:off globalDate:off
/**
* Durable T3 bearer for the Discord bot.
*
* Bootstrap (`local-bootstrap-credential`) is exchanged for a 30-day session
* token. Persist that token so a bot restart does not need the in-memory
* bootstrap grant still to be valid.
*/
import * as NodeFS from "node:fs";
import * as NodePath from "node:path";
import * as Schema from "effect/Schema";

import { expandHomePath } from "../projectAliases.ts";

export const PERSISTED_BEARER_FILENAME = "t3-bearer.json";

/** Re-bootstrap when less than this remains on the stored session. */
export const PERSISTED_BEARER_MIN_REMAINING_MS = 60 * 60 * 1000;

export const PersistedBearerSession = Schema.Struct({
accessToken: Schema.String,
expiresAt: Schema.String,
httpBaseUrl: Schema.String,
});
export type PersistedBearerSession = typeof PersistedBearerSession.Type;

const decodePersistedBearerSession = Schema.decodeUnknownSync(PersistedBearerSession);

export function persistedBearerFilePath(dataDir: string): string {
return NodePath.join(expandHomePath(dataDir), PERSISTED_BEARER_FILENAME);
}

export function normalizeHttpBaseUrl(value: string): string {
try {
return new URL(value).toString();
} catch {
return value;
}
}

export function persistedBearerExpiresAtIso(nowMs: number, expiresInSeconds: number): string {
const ttlMs = Math.max(0, expiresInSeconds) * 1000;
return new Date(nowMs + ttlMs).toISOString();
}

export function parsePersistedBearerSession(raw: unknown): PersistedBearerSession | null {
try {
const decoded = decodePersistedBearerSession(raw);
if (decoded.accessToken.trim() === "" || decoded.httpBaseUrl.trim() === "") return null;
if (!Number.isFinite(Date.parse(decoded.expiresAt))) return null;
return decoded;
} catch {
return null;
}
}

export function shouldReusePersistedBearer(input: {
readonly record: Pick<PersistedBearerSession, "expiresAt" | "httpBaseUrl" | "accessToken">;
readonly nowMs: number;
readonly httpBaseUrl: string;
readonly minRemainingMs?: number;
}): boolean {
if (input.record.accessToken.trim() === "") return false;
if (normalizeHttpBaseUrl(input.record.httpBaseUrl) !== normalizeHttpBaseUrl(input.httpBaseUrl)) {
return false;
}
const expiresAtMs = Date.parse(input.record.expiresAt);
if (!Number.isFinite(expiresAtMs)) return false;
const minRemaining = input.minRemainingMs ?? PERSISTED_BEARER_MIN_REMAINING_MS;
return expiresAtMs - input.nowMs >= minRemaining;
}

export function readPersistedBearerSession(dataDir: string): PersistedBearerSession | null {
try {
const raw = NodeFS.readFileSync(persistedBearerFilePath(dataDir), "utf8");
return parsePersistedBearerSession(JSON.parse(raw) as unknown);
} catch {
return null;
}
}

export function writePersistedBearerSession(dataDir: string, record: PersistedBearerSession): void {
const filePath = persistedBearerFilePath(dataDir);
const dir = NodePath.dirname(filePath);
NodeFS.mkdirSync(dir, { recursive: true, mode: 0o700 });
const tempPath = `${filePath}.${process.pid}.tmp`;
NodeFS.writeFileSync(tempPath, `${JSON.stringify(record)}\n`, { mode: 0o600 });
NodeFS.renameSync(tempPath, filePath);
}

export function clearPersistedBearerSession(dataDir: string): void {
try {
NodeFS.unlinkSync(persistedBearerFilePath(dataDir));
} catch {
// missing is the desired end state
}
}
33 changes: 33 additions & 0 deletions apps/discord-bot/src/t3/T3Session.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
// @effect-diagnostics nodeBuiltinImport:off - existence contract reads T3Session source on disk.
import * as NodeFS from "node:fs";
import { ProviderInstanceId } from "@t3tools/contracts";
import { describe, expect, it } from "vite-plus/test";

import {
isT3InvalidCredentialError,
isT3TransportError,
shouldContinueWaitingForT3Ready,
shouldPersistThreadModelSelectionForNextTurn,
T3_STILL_CONNECTING_MESSAGE,
} from "./T3Session.ts";

const t3SessionSource = NodeFS.readFileSync(new URL("./T3Session.ts", import.meta.url), "utf8");

describe("isT3TransportError", () => {
it("matches SocketCloseError and ConnectionTransientError", () => {
expect(isT3TransportError(new Error("SocketCloseError: 1005"))).toBe(true);
Expand All @@ -26,6 +31,34 @@ describe("isT3TransportError", () => {
});
});

describe("isT3InvalidCredentialError", () => {
it("matches bootstrap/bearer rejection copy", () => {
expect(
isT3InvalidCredentialError(
new Error(
"Bootstrap failed: The environment rejected this client's credentials (invalid_credential).",
),
),
).toBe(true);
expect(isT3InvalidCredentialError(new Error('reason: "invalid_credential"'))).toBe(true);
});

it("does not match transport or timeout copy", () => {
expect(isT3InvalidCredentialError(new Error("SocketCloseError: 1005"))).toBe(false);
expect(isT3InvalidCredentialError(new Error(T3_STILL_CONNECTING_MESSAGE))).toBe(false);
});
});

describe("persisted T3 bearer", () => {
it("reuses a stored bearer on connect and falls back to bootstrap when rejected", () => {
expect(t3SessionSource).toContain("readPersistedBearerSession(botConfig.dataDir)");
expect(t3SessionSource).toContain("shouldReusePersistedBearer({");
expect(t3SessionSource).toContain("writePersistedBearerSession(botConfig.dataDir");
expect(t3SessionSource).toContain("Persisted T3 bearer rejected; falling back to bootstrap");
expect(t3SessionSource).toContain("clearPersistedBearerSession(botConfig.dataDir)");
});
});

describe("shouldContinueWaitingForT3Ready", () => {
it("returns ready as soon as the shell is live", () => {
expect(shouldContinueWaitingForT3Ready({ ready: true, elapsedMs: 0, timeoutMs: 1_000 })).toBe(
Expand Down
83 changes: 70 additions & 13 deletions apps/discord-bot/src/t3/T3Session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,13 @@ import { formatThreadTitle } from "../presentation/messages.ts";
import { normalizeWorkspacePath } from "../presentation/mentions.ts";
import { followOrchestrationThread } from "./DiscordThreadFollower.ts";
import { newCommandId, newMessageId, newThreadId, shortId } from "./ids.ts";
import {
clearPersistedBearerSession,
persistedBearerExpiresAtIso,
readPersistedBearerSession,
shouldReusePersistedBearer,
writePersistedBearerSession,
} from "./PersistedBearer.ts";

function wsBaseUrl(httpBaseUrl: string): string {
const url = new URL(httpBaseUrl);
Expand Down Expand Up @@ -98,6 +105,12 @@ export function isT3TransportError(cause: unknown): boolean {
return false;
}

/** True when T3 rejected a bearer/bootstrap credential (re-bootstrap, don't loop). */
export function isT3InvalidCredentialError(cause: unknown): boolean {
const msg = messageFromCause(cause);
return /invalid_credential|rejected this client's credentials/i.test(msg);
}

/**
* User-facing copy when the bot waited for T3 to become ready and the deadline
* elapsed (boot/reconnect still incomplete). Mentions normally **wait** instead
Expand Down Expand Up @@ -685,29 +698,73 @@ export const makeT3Session = (botConfig: DiscordBotConfig) =>
: new T3SessionError(messageFromCause(cause), { cause }),
});

const persistBootstrappedBearer = (accessToken: string, expiresInSeconds: number) =>
Effect.gen(function* () {
const nowMs = yield* Clock.currentTimeMillis;
try {
writePersistedBearerSession(botConfig.dataDir, {
accessToken,
expiresAt: persistedBearerExpiresAtIso(nowMs, expiresInSeconds),
httpBaseUrl: botConfig.t3HttpBaseUrl,
});
} catch (cause) {
yield* Effect.logWarning(
`Could not persist T3 bearer session: ${messageFromCause(cause)}`,
);
}
});

const connectWithBootstrapCredential = () =>
Effect.gen(function* () {
const tokenSession = yield* Effect.tryPromise({
try: () =>
runtime.runPromise(
bootstrapRemoteBearerSession({
httpBaseUrl: botConfig.t3HttpBaseUrl,
credential: botConfig.t3BootstrapCredential!,
clientMetadata: { label: "T3 Discord Bot", deviceType: "bot" },
}),
),
catch: (cause) =>
new T3SessionError(`Bootstrap failed: ${messageFromCause(cause)}`, { cause }),
});
yield* persistBootstrappedBearer(tokenSession.access_token, tokenSession.expires_in);
yield* connectPrepared(botConfig.t3HttpBaseUrl, tokenSession.access_token);
});

const connect = () =>
Effect.gen(function* () {
if (botConfig.t3BearerToken !== undefined && botConfig.t3BearerToken !== "") {
yield* connectPrepared(botConfig.t3HttpBaseUrl, botConfig.t3BearerToken);
return;
}

const nowMs = yield* Clock.currentTimeMillis;
const persisted = readPersistedBearerSession(botConfig.dataDir);
if (
persisted !== null &&
shouldReusePersistedBearer({
record: persisted,
nowMs,
httpBaseUrl: botConfig.t3HttpBaseUrl,
})
) {
const persistedConnect = yield* Effect.result(
connectPrepared(botConfig.t3HttpBaseUrl, persisted.accessToken),
);
if (Result.isSuccess(persistedConnect)) return;
if (!isT3InvalidCredentialError(persistedConnect.failure)) {
return yield* Effect.fail(persistedConnect.failure);
}
yield* Effect.logWarning("Persisted T3 bearer rejected; falling back to bootstrap");
clearPersistedBearerSession(botConfig.dataDir);
}

if (
botConfig.t3BootstrapCredential !== undefined &&
botConfig.t3BootstrapCredential !== ""
) {
const tokenSession = yield* Effect.tryPromise({
try: () =>
runtime.runPromise(
bootstrapRemoteBearerSession({
httpBaseUrl: botConfig.t3HttpBaseUrl,
credential: botConfig.t3BootstrapCredential!,
clientMetadata: { label: "T3 Discord Bot", deviceType: "bot" },
}),
),
catch: (cause) =>
new T3SessionError(`Bootstrap failed: ${messageFromCause(cause)}`, { cause }),
});
yield* connectPrepared(botConfig.t3HttpBaseUrl, tokenSession.access_token);
yield* connectWithBootstrapCredential();
return;
}
yield* connectPrepared(botConfig.t3HttpBaseUrl);
Expand Down
Loading
Loading