mirror of
https://github.com/moltbot/moltbot.git
synced 2026-05-10 20:45:15 +00:00
refactor: store msteams delegated tokens in sqlite
This commit is contained in:
@@ -274,9 +274,10 @@ The remaining cleanup is mostly consolidation and deletion:
|
||||
- The generic plugin SDK persistent-dedupe helper no longer exposes file-shaped
|
||||
options. Callers provide SQLite scope keys and durable dedupe rows live in
|
||||
shared plugin state.
|
||||
- Microsoft Teams SSO tokens moved from a locked JSON file to SQLite plugin
|
||||
state. Doctor imports `msteams-sso-tokens.json`, rebuilds canonical token
|
||||
keys from payloads, and removes the source file.
|
||||
- Microsoft Teams SSO and delegated OAuth tokens moved from locked JSON files
|
||||
to SQLite plugin state. Doctor imports `msteams-sso-tokens.json` and
|
||||
`msteams-delegated.json`, rebuilds canonical SSO token keys from payloads,
|
||||
and removes the source files.
|
||||
- Matrix sync cache state moved from `bot-storage.json` to SQLite plugin
|
||||
state. Doctor imports legacy raw or wrapped sync payloads and removes the
|
||||
source file.
|
||||
@@ -603,10 +604,11 @@ Move these into the global database:
|
||||
`*.telegram-sent-messages.json`, `*.telegram-topic-names.json`, and
|
||||
`thread-bindings-*.json`; the Telegram doctor/setup migration imports and
|
||||
removes the legacy files.
|
||||
- Microsoft Teams conversations, polls, pending uploads, and feedback
|
||||
learnings now use SQLite plugin state/blob namespaces
|
||||
(`conversations`, `polls`, `pending-uploads`, `feedback-learnings`) instead
|
||||
of `msteams-conversations.json`, `msteams-polls.json`,
|
||||
- Microsoft Teams conversations, polls, delegated tokens, pending uploads, and
|
||||
feedback learnings now use SQLite plugin state/blob namespaces
|
||||
(`conversations`, `polls`, `delegated-tokens`, `pending-uploads`,
|
||||
`feedback-learnings`) instead of `msteams-conversations.json`,
|
||||
`msteams-polls.json`, `msteams-delegated.json`,
|
||||
`msteams-pending-uploads.json`, and `*.learnings.json`; the Microsoft Teams
|
||||
doctor/setup migration imports and removes the legacy files.
|
||||
- Matrix sync cache, storage metadata, thread bindings, inbound dedupe markers,
|
||||
@@ -1012,6 +1014,7 @@ Add a repo check that fails new runtime writes to legacy state paths:
|
||||
- Telegram `thread-bindings-*.json`
|
||||
- Microsoft Teams `msteams-conversations.json`
|
||||
- Microsoft Teams `msteams-polls.json`
|
||||
- Microsoft Teams `msteams-delegated.json`
|
||||
- Microsoft Teams `msteams-pending-uploads.json`
|
||||
- Microsoft Teams `*.learnings.json`
|
||||
- Matrix `thread-bindings.json`
|
||||
|
||||
@@ -14,6 +14,7 @@ import { setMSTeamsRuntime } from "./runtime.js";
|
||||
import { createMSTeamsSsoTokenStore } from "./sso-token-store.js";
|
||||
import { detectMSTeamsLegacyStateMigrations } from "./state-migrations.js";
|
||||
import { msteamsRuntimeStub } from "./test-runtime.js";
|
||||
import { loadDelegatedTokens } from "./token.js";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
@@ -160,6 +161,30 @@ describe("Microsoft Teams legacy state migrations", () => {
|
||||
expect(fs.existsSync(tokenFile)).toBe(false);
|
||||
});
|
||||
|
||||
it("imports delegated token files into SQLite plugin state", async () => {
|
||||
const stateDir = makeStateDir();
|
||||
const tokenFile = path.join(stateDir, "msteams-delegated.json");
|
||||
fs.writeFileSync(
|
||||
tokenFile,
|
||||
`${JSON.stringify({
|
||||
accessToken: "access-token",
|
||||
refreshToken: "refresh-token",
|
||||
expiresAt: 1_900_000_000_000,
|
||||
scopes: ["ChatMessage.Send", "offline_access"],
|
||||
userPrincipalName: "user@example.com",
|
||||
})}\n`,
|
||||
);
|
||||
|
||||
await applyPlan(stateDir, "Microsoft Teams delegated token");
|
||||
|
||||
expect(loadDelegatedTokens()).toMatchObject({
|
||||
accessToken: "access-token",
|
||||
refreshToken: "refresh-token",
|
||||
userPrincipalName: "user@example.com",
|
||||
});
|
||||
expect(fs.existsSync(tokenFile)).toBe(false);
|
||||
});
|
||||
|
||||
it("imports feedback learning files into SQLite plugin state", async () => {
|
||||
const stateDir = makeStateDir();
|
||||
const learningFile = path.join(stateDir, "bXN0ZWFtczp1c2VyMQ.learnings.json");
|
||||
|
||||
@@ -12,6 +12,11 @@ import {
|
||||
MSTEAMS_SSO_TOKEN_STORE_FILENAME,
|
||||
parseMSTeamsSsoTokenStoreData,
|
||||
} from "./sso-token-store.js";
|
||||
import {
|
||||
MSTEAMS_DELEGATED_TOKEN_FILENAME,
|
||||
MSTEAMS_DELEGATED_TOKEN_NAMESPACE,
|
||||
parseMSTeamsDelegatedTokens,
|
||||
} from "./token.js";
|
||||
|
||||
const MSTEAMS_PLUGIN_ID = "msteams";
|
||||
const PENDING_UPLOAD_TTL_MS = 5 * 60 * 1000;
|
||||
@@ -152,6 +157,26 @@ function importSsoTokens(filePath: string, env: NodeJS.ProcessEnv): ImportResult
|
||||
return { imported, warnings: [] };
|
||||
}
|
||||
|
||||
function importDelegatedTokens(filePath: string, env: NodeJS.ProcessEnv): ImportResult {
|
||||
const tokens = parseMSTeamsDelegatedTokens(readJsonFile(filePath));
|
||||
if (!tokens) {
|
||||
return {
|
||||
imported: 0,
|
||||
warnings: [`Skipped invalid Microsoft Teams delegated token file: ${filePath}`],
|
||||
};
|
||||
}
|
||||
upsertPluginStateMigrationEntry({
|
||||
pluginId: MSTEAMS_PLUGIN_ID,
|
||||
namespace: MSTEAMS_DELEGATED_TOKEN_NAMESPACE,
|
||||
key: "current",
|
||||
value: tokens,
|
||||
createdAt: Date.now(),
|
||||
env,
|
||||
});
|
||||
fs.rmSync(filePath, { force: true });
|
||||
return { imported: 1, warnings: [] };
|
||||
}
|
||||
|
||||
function importPendingUploads(filePath: string, env: NodeJS.ProcessEnv): ImportResult {
|
||||
const raw = readJsonFile(filePath);
|
||||
if (!isRecord(raw) || raw.version !== 1 || !isRecord(raw.uploads)) {
|
||||
@@ -345,6 +370,17 @@ export function detectMSTeamsLegacyStateMigrations(params: {
|
||||
}),
|
||||
);
|
||||
}
|
||||
const delegatedTokens = path.join(params.stateDir, MSTEAMS_DELEGATED_TOKEN_FILENAME);
|
||||
if (fs.existsSync(delegatedTokens)) {
|
||||
plans.push(
|
||||
pluginStatePlan({
|
||||
label: "Microsoft Teams delegated token",
|
||||
sourcePath: delegatedTokens,
|
||||
namespace: MSTEAMS_DELEGATED_TOKEN_NAMESPACE,
|
||||
importSource: importDelegatedTokens,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (collectLearningFiles(params.stateDir).length > 0) {
|
||||
plans.push(
|
||||
pluginStatePlan({
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { resetPluginStateStoreForTests } from "openclaw/plugin-sdk/plugin-state-runtime";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { readAccessToken } from "./token-response.js";
|
||||
import { hasConfiguredMSTeamsCredentials, resolveMSTeamsCredentials } from "./token.js";
|
||||
import {
|
||||
loadDelegatedTokens,
|
||||
parseMSTeamsDelegatedTokens,
|
||||
saveDelegatedTokens,
|
||||
hasConfiguredMSTeamsCredentials,
|
||||
resolveMSTeamsCredentials,
|
||||
} from "./token.js";
|
||||
|
||||
vi.mock("./secret-input.js", () => ({
|
||||
normalizeSecretInputString: (v: unknown) =>
|
||||
@@ -19,6 +29,7 @@ const ENV_KEYS = [
|
||||
"MSTEAMS_CERTIFICATE_THUMBPRINT",
|
||||
"MSTEAMS_USE_MANAGED_IDENTITY",
|
||||
"MSTEAMS_MANAGED_IDENTITY_CLIENT_ID",
|
||||
"OPENCLAW_STATE_DIR",
|
||||
] as const;
|
||||
|
||||
let savedEnv: Record<string, string | undefined> = {};
|
||||
@@ -243,6 +254,63 @@ describe("token – backward compatibility", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("delegated token storage", () => {
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
saveAndClearEnv();
|
||||
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-msteams-token-"));
|
||||
tempDirs.push(stateDir);
|
||||
process.env.OPENCLAW_STATE_DIR = stateDir;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetPluginStateStoreForTests();
|
||||
restoreEnv();
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("stores delegated tokens in SQLite plugin state", () => {
|
||||
saveDelegatedTokens({
|
||||
accessToken: "access-token",
|
||||
refreshToken: "refresh-token",
|
||||
expiresAt: 1_900_000_000_000,
|
||||
scopes: ["ChatMessage.Send", "offline_access"],
|
||||
userPrincipalName: "user@example.com",
|
||||
});
|
||||
|
||||
expect(loadDelegatedTokens()).toEqual({
|
||||
accessToken: "access-token",
|
||||
refreshToken: "refresh-token",
|
||||
expiresAt: 1_900_000_000_000,
|
||||
scopes: ["ChatMessage.Send", "offline_access"],
|
||||
userPrincipalName: "user@example.com",
|
||||
});
|
||||
expect(
|
||||
fs.existsSync(path.join(process.env.OPENCLAW_STATE_DIR ?? "", "msteams-delegated.json")),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects invalid delegated token payloads", () => {
|
||||
expect(parseMSTeamsDelegatedTokens({ accessToken: "a" })).toBeNull();
|
||||
expect(
|
||||
parseMSTeamsDelegatedTokens({
|
||||
accessToken: "a",
|
||||
refreshToken: "r",
|
||||
expiresAt: 1,
|
||||
scopes: ["scope"],
|
||||
}),
|
||||
).toEqual({
|
||||
accessToken: "a",
|
||||
refreshToken: "r",
|
||||
expiresAt: 1,
|
||||
scopes: ["scope"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("readAccessToken", () => {
|
||||
it("reads string and object token forms", () => {
|
||||
expect(readAccessToken("abc")).toBe("abc");
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { basename, dirname } from "node:path";
|
||||
import { privateFileStoreSync } from "openclaw/plugin-sdk/security-runtime";
|
||||
import { createPluginStateSyncKeyedStore } from "openclaw/plugin-sdk/plugin-state-runtime";
|
||||
import type { MSTeamsConfig } from "../runtime-api.js";
|
||||
import type { MSTeamsDelegatedTokens } from "./oauth.shared.js";
|
||||
import { refreshMSTeamsDelegatedTokens } from "./oauth.token.js";
|
||||
@@ -9,7 +7,6 @@ import {
|
||||
normalizeResolvedSecretInputString,
|
||||
normalizeSecretInputString,
|
||||
} from "./secret-input.js";
|
||||
import { resolveMSTeamsCredentialFilePath } from "./storage.js";
|
||||
|
||||
// ── Credential types ───────────────────────────────────────────────────────
|
||||
|
||||
@@ -142,24 +139,56 @@ export function resolveMSTeamsCredentials(cfg?: MSTeamsConfig): MSTeamsCredentia
|
||||
// Delegated token storage / resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DELEGATED_TOKEN_FILENAME = "msteams-delegated.json";
|
||||
export const MSTEAMS_DELEGATED_TOKEN_FILENAME = "msteams-delegated.json";
|
||||
export const MSTEAMS_DELEGATED_TOKEN_NAMESPACE = "delegated-tokens";
|
||||
const MSTEAMS_PLUGIN_ID = "msteams";
|
||||
const MSTEAMS_DELEGATED_TOKEN_KEY = "current";
|
||||
|
||||
function resolveDelegatedTokenPath(): string {
|
||||
return resolveMSTeamsCredentialFilePath({ filename: DELEGATED_TOKEN_FILENAME });
|
||||
const delegatedTokenStore = createPluginStateSyncKeyedStore<MSTeamsDelegatedTokens>(
|
||||
MSTEAMS_PLUGIN_ID,
|
||||
{
|
||||
namespace: MSTEAMS_DELEGATED_TOKEN_NAMESPACE,
|
||||
maxEntries: 8,
|
||||
},
|
||||
);
|
||||
|
||||
export function parseMSTeamsDelegatedTokens(value: unknown): MSTeamsDelegatedTokens | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
const tokens = value as Partial<MSTeamsDelegatedTokens>;
|
||||
if (
|
||||
typeof tokens.accessToken !== "string" ||
|
||||
!tokens.accessToken ||
|
||||
typeof tokens.refreshToken !== "string" ||
|
||||
!tokens.refreshToken ||
|
||||
typeof tokens.expiresAt !== "number" ||
|
||||
!Number.isFinite(tokens.expiresAt) ||
|
||||
!Array.isArray(tokens.scopes) ||
|
||||
tokens.scopes.some((scope) => typeof scope !== "string" || !scope)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
accessToken: tokens.accessToken,
|
||||
refreshToken: tokens.refreshToken,
|
||||
expiresAt: tokens.expiresAt,
|
||||
scopes: [...tokens.scopes],
|
||||
...(typeof tokens.userPrincipalName === "string" && tokens.userPrincipalName
|
||||
? { userPrincipalName: tokens.userPrincipalName }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function loadDelegatedTokens(): MSTeamsDelegatedTokens | undefined {
|
||||
try {
|
||||
const content = readFileSync(resolveDelegatedTokenPath(), "utf8");
|
||||
return JSON.parse(content) as MSTeamsDelegatedTokens;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
return (
|
||||
parseMSTeamsDelegatedTokens(delegatedTokenStore.lookup(MSTEAMS_DELEGATED_TOKEN_KEY)) ??
|
||||
undefined
|
||||
);
|
||||
}
|
||||
|
||||
export function saveDelegatedTokens(tokens: MSTeamsDelegatedTokens): void {
|
||||
const tokenPath = resolveDelegatedTokenPath();
|
||||
privateFileStoreSync(dirname(tokenPath)).writeJson(basename(tokenPath), tokens);
|
||||
delegatedTokenStore.register(MSTEAMS_DELEGATED_TOKEN_KEY, tokens);
|
||||
}
|
||||
|
||||
export async function resolveDelegatedAccessToken(params: {
|
||||
|
||||
@@ -98,6 +98,7 @@ const legacyStoreMarkers = [
|
||||
pattern: /\bmsteams-pending-uploads\.json\b/u,
|
||||
},
|
||||
{ label: "Microsoft Teams SSO token JSON", pattern: /\bmsteams-sso-tokens\.json\b/u },
|
||||
{ label: "Microsoft Teams delegated token JSON", pattern: /\bmsteams-delegated\.json\b/u },
|
||||
{ label: "Microsoft Teams feedback learnings JSON", pattern: /\.learnings\.json\b/u },
|
||||
{ label: "Matrix sync store JSON", pattern: /\bbot-storage\.json\b/u },
|
||||
{ label: "Matrix storage metadata JSON", pattern: /\bstorage-meta\.json\b/u },
|
||||
|
||||
Reference in New Issue
Block a user