mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-13 06:03:39 -06:00
fix: adopt Tailscale names and avatars into user profiles (#119479)
* fix(gateway): adopt Tailscale profile identities * fix(gateway): normalize Tailscale identity subjects * fix(gateway): preserve Tailscale identity provenance * fix(gateway): detach Tailscale avatar adoption
This commit is contained in:
committed by
GitHub
parent
cb3890b921
commit
dca3ed16de
@@ -17,6 +17,7 @@ const mocks = vi.hoisted(() => ({
|
||||
maybeRepairPluginOpenClawHostLinks: vi.fn(),
|
||||
maybeRepairLegacyOAuthSidecarProfiles: vi.fn(),
|
||||
migrateLegacyOnboardingRecommendationsScope: vi.fn(),
|
||||
migrateLegacyTailscaleProfileIdentities: vi.fn(),
|
||||
maybeMigrateAuthProfileJsonStoresToSqlite: vi.fn(),
|
||||
maybeRepairOpenAICodexAuthConfig: vi.fn(),
|
||||
maybeRepairOpenPolicyAllowFrom: vi.fn(),
|
||||
@@ -51,6 +52,10 @@ vi.mock("../../infra/state-migrations.onboarding-recommendations.js", () => ({
|
||||
migrateLegacyOnboardingRecommendationsScope: mocks.migrateLegacyOnboardingRecommendationsScope,
|
||||
}));
|
||||
|
||||
vi.mock("../../state/user-profiles-tailscale-migration.js", () => ({
|
||||
migrateLegacyTailscaleProfileIdentities: mocks.migrateLegacyTailscaleProfileIdentities,
|
||||
}));
|
||||
|
||||
vi.mock("../doctor-auth-flat-profiles.js", () => ({
|
||||
collectOpenAICodexAuthProfileStoreIdMap: mocks.collectOpenAICodexAuthProfileStoreIdMap,
|
||||
maybeMigrateAuthProfileJsonStoresToSqlite: mocks.maybeMigrateAuthProfileJsonStoresToSqlite,
|
||||
@@ -259,6 +264,7 @@ describe("doctor repair sequencing", () => {
|
||||
changes: [],
|
||||
warnings: [],
|
||||
});
|
||||
mocks.migrateLegacyTailscaleProfileIdentities.mockReturnValue({ changes: [], warnings: [] });
|
||||
mocks.collectOpenAICodexAuthProfileStoreIdMap.mockReturnValue(new Map());
|
||||
mocks.maybeMigrateAuthProfileJsonStoresToSqlite.mockResolvedValue({
|
||||
detected: [],
|
||||
@@ -327,6 +333,25 @@ describe("doctor repair sequencing", () => {
|
||||
expect(result.warningNotes).toContain("Migration warning.");
|
||||
});
|
||||
|
||||
it("runs the doctor-only Tailscale profile identity migration", async () => {
|
||||
const env = { OPENCLAW_STATE_DIR: "/tmp/openclaw-doctor-test" };
|
||||
const candidate = {} as OpenClawConfig;
|
||||
mocks.migrateLegacyTailscaleProfileIdentities.mockReturnValue({
|
||||
changes: ["Migrated Tailscale profile identity."],
|
||||
warnings: ["Tailscale identity conflict."],
|
||||
});
|
||||
|
||||
const result = await runDoctorRepairSequence({
|
||||
state: { cfg: candidate, candidate, pendingChanges: false, fixHints: [] },
|
||||
doctorFixCommand: "openclaw doctor --fix",
|
||||
env,
|
||||
});
|
||||
|
||||
expect(mocks.migrateLegacyTailscaleProfileIdentities).toHaveBeenCalledWith({ env });
|
||||
expect(result.changeNotes).toContain("Migrated Tailscale profile identity.");
|
||||
expect(result.warningNotes).toContain("Tailscale identity conflict.");
|
||||
});
|
||||
|
||||
it("retains the exact auth profile map after import for later session-owner repair", async () => {
|
||||
const env = { OPENCLAW_STATE_DIR: "/tmp/openclaw-doctor-test" };
|
||||
const candidate = {} as OpenClawConfig;
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
materializePluginAutoEnableCandidates,
|
||||
} from "../../config/plugin-auto-enable.js";
|
||||
import { migrateLegacyOnboardingRecommendationsScope } from "../../infra/state-migrations.onboarding-recommendations.js";
|
||||
import { migrateLegacyTailscaleProfileIdentities } from "../../state/user-profiles-tailscale-migration.js";
|
||||
import {
|
||||
collectOpenAICodexAuthProfileStoreIdMap,
|
||||
maybeMigrateAuthProfileJsonStoresToSqlite,
|
||||
@@ -230,6 +231,7 @@ export async function runDoctorRepairSequence(params: {
|
||||
|
||||
await applyRepairStages([maybeRepairLegacyToolsBySenderKeys, maybeRepairExecSafeBinProfiles]);
|
||||
appendRepairNotes(await migrateLegacySkillWorkshopProposals({ config: state.candidate, env }));
|
||||
appendRepairNotes(migrateLegacyTailscaleProfileIdentities({ env }));
|
||||
appendRepairNotes(await cleanupLegacyPluginDependencyState({ env }));
|
||||
appendRepairNotes(
|
||||
migrateLegacyOnboardingRecommendationsScope({
|
||||
|
||||
@@ -55,15 +55,16 @@ function createTailscaleForwardedReq(): TailscaleForwardedRequest {
|
||||
"x-forwarded-for": "100.64.0.1",
|
||||
"x-forwarded-proto": "https",
|
||||
"x-forwarded-host": "ai-hub.bone-egret.ts.net",
|
||||
"tailscale-user-login": "peter",
|
||||
"tailscale-user-login": "peter@github",
|
||||
"tailscale-user-name": "Peter",
|
||||
"tailscale-user-profile-pic": "https://avatars.example.test/peter.png",
|
||||
"sec-fetch-site": "same-origin",
|
||||
},
|
||||
} as unknown as TailscaleForwardedRequest;
|
||||
}
|
||||
|
||||
function createTailscaleWhois() {
|
||||
return async () => ({ login: "peter", name: "Peter" });
|
||||
return async () => ({ login: "peter@github", name: "Peter" });
|
||||
}
|
||||
|
||||
function createAvatarBrowserOriginPolicy(
|
||||
@@ -499,7 +500,12 @@ describe("gateway auth", () => {
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.method).toBe("tailscale");
|
||||
expect(res.user).toBe("peter");
|
||||
expect(res.user).toBe("peter@github");
|
||||
expect(res.tailscaleIdentity).toEqual({
|
||||
login: "peter@github",
|
||||
name: "Peter",
|
||||
profilePic: "https://avatars.example.test/peter.png",
|
||||
});
|
||||
});
|
||||
|
||||
it("allows an origin-less same-origin image through the profile avatar surface", async () => {
|
||||
@@ -514,7 +520,7 @@ describe("gateway auth", () => {
|
||||
browserOriginPolicy: createAvatarBrowserOriginPolicy(req),
|
||||
});
|
||||
|
||||
expect(res).toMatchObject({ ok: true, method: "tailscale", user: "peter" });
|
||||
expect(res).toMatchObject({ ok: true, method: "tailscale", user: "peter@github" });
|
||||
expect(limiter.check).toHaveBeenCalledWith("127.0.0.1", "shared-secret");
|
||||
expect(limiter.reset).toHaveBeenCalledWith("127.0.0.1", "shared-secret");
|
||||
});
|
||||
@@ -569,7 +575,7 @@ describe("gateway auth", () => {
|
||||
browserOriginPolicy: createAvatarBrowserOriginPolicy(req, ["https://control.example.com"]),
|
||||
});
|
||||
|
||||
expect(res).toMatchObject({ ok: true, method: "tailscale", user: "peter" });
|
||||
expect(res).toMatchObject({ ok: true, method: "tailscale", user: "peter@github" });
|
||||
expect(tailscaleWhois).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
@@ -716,14 +722,14 @@ describe("gateway auth", () => {
|
||||
it("enables tailscale header auth on the profile avatar HTTP wrapper", async () => {
|
||||
await expectTailscaleHeaderAuthResult({
|
||||
authorize: authorizeUserProfileAvatarHttpGatewayConnect,
|
||||
expected: { ok: true, method: "tailscale", user: "peter" },
|
||||
expected: { ok: true, method: "tailscale", user: "peter@github" },
|
||||
});
|
||||
});
|
||||
|
||||
it("enables tailscale header auth on ws control-ui auth wrapper", async () => {
|
||||
await expectTailscaleHeaderAuthResult({
|
||||
authorize: authorizeWsControlUiGatewayConnect,
|
||||
expected: { ok: true, method: "tailscale", user: "peter" },
|
||||
expected: { ok: true, method: "tailscale", user: "peter@github" },
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+6
-3
@@ -46,6 +46,8 @@ export type GatewayAuthResult = {
|
||||
| "bootstrap-token"
|
||||
| "trusted-proxy";
|
||||
user?: string;
|
||||
/** Full verified Tailscale identity; present only after header + WhoIs agreement. */
|
||||
tailscaleIdentity?: VerifiedTailscaleIdentity;
|
||||
reason?: string;
|
||||
/** Present when the request was blocked by the rate limiter. */
|
||||
rateLimited?: boolean;
|
||||
@@ -90,7 +92,7 @@ type AuthorizeGatewayConnectParams = {
|
||||
};
|
||||
};
|
||||
|
||||
type TailscaleUser = {
|
||||
type VerifiedTailscaleIdentity = {
|
||||
login: string;
|
||||
name: string;
|
||||
profilePic?: string;
|
||||
@@ -152,7 +154,7 @@ function resolveTailscaleClientIp(req?: IncomingMessage): string | undefined {
|
||||
});
|
||||
}
|
||||
|
||||
function getTailscaleUser(req?: IncomingMessage): TailscaleUser | null {
|
||||
function getTailscaleUser(req?: IncomingMessage): VerifiedTailscaleIdentity | null {
|
||||
if (!req) {
|
||||
return null;
|
||||
}
|
||||
@@ -191,7 +193,7 @@ function isTailscaleProxyRequest(req?: IncomingMessage): boolean {
|
||||
async function resolveVerifiedTailscaleUser(params: {
|
||||
req?: IncomingMessage;
|
||||
tailscaleWhois: TailscaleWhoisLookup;
|
||||
}): Promise<{ ok: true; user: TailscaleUser } | { ok: false; reason: string }> {
|
||||
}): Promise<{ ok: true; user: VerifiedTailscaleIdentity } | { ok: false; reason: string }> {
|
||||
const { req, tailscaleWhois } = params;
|
||||
const tailscaleUser = getTailscaleUser(req);
|
||||
if (!tailscaleUser) {
|
||||
@@ -571,6 +573,7 @@ async function authorizeGatewayConnectCore(
|
||||
ok: true,
|
||||
method: "tailscale",
|
||||
user: tailscaleCheck.user.login,
|
||||
tailscaleIdentity: tailscaleCheck.user,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,6 +68,8 @@ export type GatewayClient = {
|
||||
/** Client id verified against the server-approved device pairing record. */
|
||||
pairedClientId?: string;
|
||||
authenticatedUserId?: string;
|
||||
/** Verified Tailscale provider identity; generic proxy identities must not infer this. */
|
||||
authenticatedUserIsTailscaleProvider?: boolean;
|
||||
authenticatedUserProfile?: {
|
||||
profileId: string;
|
||||
displayName: string | null;
|
||||
|
||||
@@ -91,6 +91,58 @@ describe("users gateway methods", () => {
|
||||
expect(getUserProfileListItem).toHaveBeenNthCalledWith(2, profile.id);
|
||||
});
|
||||
|
||||
it("uses the connect-time provider profile without recreating an email alias", async () => {
|
||||
const providerClient = {
|
||||
authenticatedUserId: "ada@github",
|
||||
authenticatedUserIsTailscaleProvider: true,
|
||||
authenticatedUserProfile: {
|
||||
profileId: profile.id,
|
||||
displayName: "Ada",
|
||||
hasAvatar: false,
|
||||
updatedAt: 1,
|
||||
},
|
||||
connect: { scopes: ["operator.write"] },
|
||||
};
|
||||
resolveUserProfileId.mockReturnValue(profile.id);
|
||||
getUserProfileListItem.mockReturnValue({ ...profile, emails: [] });
|
||||
|
||||
const respond = await runUsersHandler("users.self", {}, providerClient);
|
||||
|
||||
expect(respond).toHaveBeenCalledWith(true, { profile: { ...profile, emails: [] } });
|
||||
expect(ensureProfileForEmail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps generic proxy identities on the legacy profile fallback", async () => {
|
||||
const proxyClient = {
|
||||
authenticatedUserId: "ada@github",
|
||||
connect: { scopes: ["operator.write"] },
|
||||
};
|
||||
ensureProfileForEmail.mockReturnValue({ id: profile.id });
|
||||
getUserProfileListItem.mockReturnValue(profile);
|
||||
|
||||
const respond = await runUsersHandler("users.self", {}, proxyClient);
|
||||
|
||||
expect(respond).toHaveBeenCalledWith(true, { profile });
|
||||
expect(ensureProfileForEmail).toHaveBeenCalledWith("ada@github");
|
||||
});
|
||||
|
||||
it("does not recreate a failed Tailscale provider snapshot as an email alias", async () => {
|
||||
const tailscaleClient = {
|
||||
authenticatedUserId: "ada@github",
|
||||
authenticatedUserIsTailscaleProvider: true,
|
||||
connect: { scopes: ["operator.write"] },
|
||||
};
|
||||
|
||||
const respond = await runUsersHandler("users.self", {}, tailscaleClient);
|
||||
|
||||
expect(respond).toHaveBeenCalledWith(
|
||||
false,
|
||||
undefined,
|
||||
expect.objectContaining({ message: "authenticated user profile is unavailable" }),
|
||||
);
|
||||
expect(ensureProfileForEmail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects users.self without an authenticated user", async () => {
|
||||
expect(
|
||||
await runUsersHandler("users.self", {}, { connect: { scopes: ["operator.write"] } }),
|
||||
@@ -215,6 +267,31 @@ describe("users gateway methods", () => {
|
||||
expect(ensureProfileForEmail).toHaveBeenCalledWith("ada@example.com");
|
||||
});
|
||||
|
||||
it("authorizes provider-owned profile edits from the connect-time profile id", async () => {
|
||||
const providerClient = {
|
||||
authenticatedUserId: "ada@github",
|
||||
authenticatedUserIsTailscaleProvider: true,
|
||||
authenticatedUserProfile: {
|
||||
profileId: profile.id,
|
||||
displayName: "Ada",
|
||||
hasAvatar: false,
|
||||
updatedAt: 1,
|
||||
},
|
||||
connect: { scopes: ["operator.write"] },
|
||||
};
|
||||
resolveUserProfileId.mockReturnValue(profile.id);
|
||||
setDisplayName.mockReturnValue(profile);
|
||||
|
||||
expect(
|
||||
await runUsersHandler(
|
||||
"users.setDisplayName",
|
||||
{ profileId: profile.id, displayName: "Ada Lovelace" },
|
||||
providerClient,
|
||||
),
|
||||
).toHaveBeenCalledWith(true, { profile });
|
||||
expect(ensureProfileForEmail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("denies an identified write caller changing another profile's avatar", async () => {
|
||||
ensureProfileForEmail.mockReturnValue(profile);
|
||||
resolveUserProfileId.mockReturnValue("profile-2");
|
||||
|
||||
@@ -49,6 +49,24 @@ function profileError(error: unknown) {
|
||||
return errorShape(ErrorCodes.UNAVAILABLE, formatErrorMessage(error));
|
||||
}
|
||||
|
||||
function resolveAuthenticatedProfileId(
|
||||
client: GatewayRequestHandlerOptions["client"],
|
||||
): string | undefined {
|
||||
if (client?.authenticatedUserProfile?.profileId) {
|
||||
return resolveUserProfileId(client.authenticatedUserProfile.profileId);
|
||||
}
|
||||
const authenticatedUserId = client?.authenticatedUserId;
|
||||
if (!authenticatedUserId) {
|
||||
return undefined;
|
||||
}
|
||||
// A failed Tailscale profile snapshot must not recreate its provider login
|
||||
// through the legacy email resolver on a later self-profile request.
|
||||
if (client.authenticatedUserIsTailscaleProvider) {
|
||||
return undefined;
|
||||
}
|
||||
return ensureProfileForEmail(authenticatedUserId).id;
|
||||
}
|
||||
|
||||
function canMutateProfile(
|
||||
client: GatewayRequestHandlerOptions["client"],
|
||||
profileId: string,
|
||||
@@ -56,10 +74,11 @@ function canMutateProfile(
|
||||
if (client?.connect.scopes?.includes(ADMIN_SCOPE)) {
|
||||
return true;
|
||||
}
|
||||
const authenticatedUserId = client?.authenticatedUserId;
|
||||
return authenticatedUserId
|
||||
? ensureProfileForEmail(authenticatedUserId).id === resolveUserProfileId(profileId)
|
||||
: false;
|
||||
const authenticatedProfileId = resolveAuthenticatedProfileId(client);
|
||||
return (
|
||||
authenticatedProfileId !== undefined &&
|
||||
authenticatedProfileId === resolveUserProfileId(profileId)
|
||||
);
|
||||
}
|
||||
|
||||
function requireProfileMutationAccess(
|
||||
@@ -102,8 +121,16 @@ export const usersHandlers: GatewayRequestHandlers = {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const profile = ensureProfileForEmail(client.authenticatedUserId);
|
||||
respond(true, { profile: getUserProfileListItem(profile.id) });
|
||||
const profileId = resolveAuthenticatedProfileId(client);
|
||||
if (!profileId) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.UNAVAILABLE, "authenticated user profile is unavailable"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
respond(true, { profile: getUserProfileListItem(profileId) });
|
||||
} catch (error) {
|
||||
respond(false, undefined, profileError(error));
|
||||
}
|
||||
|
||||
@@ -19,7 +19,12 @@ import { loadVoiceWakeRoutingConfig } from "../../../infra/voicewake-routing.js"
|
||||
import { loadVoiceWakeConfig } from "../../../infra/voicewake.js";
|
||||
import { resolveLocalNodeId } from "../../../node-host/local-id.js";
|
||||
import { recordRemoteNodeInfo, refreshRemoteNodeBins } from "../../../skills/runtime/remote.js";
|
||||
import { ensureProfileForEmail } from "../../../state/user-profiles.js";
|
||||
import { classifyTailscaleLogin } from "../../../state/user-profiles-tailscale-login.js";
|
||||
import {
|
||||
adoptTailscaleProfileAvatar,
|
||||
ensureProfileForEmail,
|
||||
ensureProfileForTailscaleIdentity,
|
||||
} from "../../../state/user-profiles.js";
|
||||
import {
|
||||
isBrowserCopilotClient,
|
||||
isEphemeralGatewayClient,
|
||||
@@ -42,6 +47,7 @@ import { formatUserProfileAvatarPath } from "../../user-profiles-http-path.js";
|
||||
import { formatForLog, logWs } from "../../ws-log.js";
|
||||
import { truncateCloseReason } from "../close-reason.js";
|
||||
import { incrementPresenceVersion } from "../health-state.js";
|
||||
import { broadcastPresenceSnapshot } from "../presence-events.js";
|
||||
import type { GatewayWsClient } from "../ws-types.js";
|
||||
import { sendGatewayHello } from "./connect-hello.js";
|
||||
import { prepareGatewayNodeConnect } from "./connect-node-session.js";
|
||||
@@ -175,6 +181,10 @@ export async function attachAuthenticatedGatewayConnect(
|
||||
: connId
|
||||
: undefined;
|
||||
const authenticatedUserId = normalizeOptionalString(authResult.user);
|
||||
const authenticatedUserIsTailscaleProvider = Boolean(
|
||||
authResult.tailscaleIdentity &&
|
||||
classifyTailscaleLogin(authResult.tailscaleIdentity.login).kind === "provider",
|
||||
);
|
||||
|
||||
if (isClosed()) {
|
||||
await releasePendingNodePairingCleanup();
|
||||
@@ -188,8 +198,10 @@ export async function attachAuthenticatedGatewayConnect(
|
||||
let authenticatedUserProfile: GatewayWsClient["authenticatedUserProfile"];
|
||||
if (authenticatedUserId) {
|
||||
try {
|
||||
const profile = ensureProfileForEmail(authenticatedUserId);
|
||||
// Profile metadata is a connect-time snapshot; edits become visible after reconnect.
|
||||
const profile = authResult.tailscaleIdentity
|
||||
? ensureProfileForTailscaleIdentity(authResult.tailscaleIdentity)
|
||||
: ensureProfileForEmail(authenticatedUserId);
|
||||
// User edits become visible after reconnect; detached provider-avatar adoption refreshes below.
|
||||
authenticatedUserProfile = {
|
||||
profileId: profile.id,
|
||||
displayName: profile.displayName,
|
||||
@@ -197,7 +209,7 @@ export async function attachAuthenticatedGatewayConnect(
|
||||
updatedAt: profile.updatedAt,
|
||||
};
|
||||
} catch (error) {
|
||||
// Profile storage must not block login; retain the legacy email-only identity on failure.
|
||||
// Profile storage and best-effort provider metadata must never block login.
|
||||
logWsControl.warn(
|
||||
`user profile resolution failed conn=${connId} user=${formatForLog(authenticatedUserId)}: ${formatForLog(error)}`,
|
||||
);
|
||||
@@ -309,6 +321,7 @@ export async function attachAuthenticatedGatewayConnect(
|
||||
sharedGatewaySessionGeneration: sessionSharedGatewaySessionGeneration,
|
||||
presenceKey,
|
||||
...(authenticatedUserId ? { authenticatedUserId } : {}),
|
||||
...(authenticatedUserIsTailscaleProvider ? { authenticatedUserIsTailscaleProvider: true } : {}),
|
||||
...(authenticatedUserProfile ? { authenticatedUserProfile } : {}),
|
||||
clientIp: reportedClientIp,
|
||||
...(internal ? { internal } : {}),
|
||||
@@ -441,6 +454,30 @@ export async function attachAuthenticatedGatewayConnect(
|
||||
);
|
||||
}
|
||||
|
||||
const buildAuthenticatedPresenceUser = () => {
|
||||
if (!authenticatedUserId) {
|
||||
return undefined;
|
||||
}
|
||||
if (!authenticatedUserProfile) {
|
||||
return {
|
||||
id: authenticatedUserId,
|
||||
...(authenticatedUserIsTailscaleProvider ? {} : { email: authenticatedUserId }),
|
||||
};
|
||||
}
|
||||
return {
|
||||
id: authenticatedUserProfile.profileId,
|
||||
...(authenticatedUserIsTailscaleProvider ? {} : { email: authenticatedUserId }),
|
||||
...(authenticatedUserProfile.displayName
|
||||
? { name: authenticatedUserProfile.displayName }
|
||||
: {}),
|
||||
// This authenticated route resolves the uploaded avatar first, then the
|
||||
// gateway-side Gravatar proxy, so clients never need an email-hash URL.
|
||||
// The revision changes when the profile avatar changes, so reconnecting
|
||||
// viewers refetch instead of reusing a stale route response.
|
||||
avatarUrl: `${formatUserProfileAvatarPath(authenticatedUserProfile.profileId)}?v=${authenticatedUserProfile.updatedAt}`,
|
||||
};
|
||||
};
|
||||
|
||||
if (presenceKey) {
|
||||
upsertPresence(presenceKey, {
|
||||
host: connectParams.client.displayName ?? connectParams.client.id ?? os.hostname(),
|
||||
@@ -454,25 +491,7 @@ export async function attachAuthenticatedGatewayConnect(
|
||||
roles: [role],
|
||||
scopes,
|
||||
instanceId: role === "node" ? (device?.id ?? instanceId) : instanceId,
|
||||
...(authenticatedUserId
|
||||
? {
|
||||
user: authenticatedUserProfile
|
||||
? {
|
||||
id: authenticatedUserProfile.profileId,
|
||||
email: authenticatedUserId,
|
||||
...(authenticatedUserProfile.displayName
|
||||
? { name: authenticatedUserProfile.displayName }
|
||||
: {}),
|
||||
// This authenticated route resolves the uploaded avatar first, then the
|
||||
// gateway-side Gravatar proxy, so clients never need an email-hash URL.
|
||||
// The ?v=<updatedAt> revision changes when the profile (avatar) is
|
||||
// updated, so a reconnecting viewer's <img> refetches instead of reusing
|
||||
// a stale cached image for the unchanged route.
|
||||
avatarUrl: `${formatUserProfileAvatarPath(authenticatedUserProfile.profileId)}?v=${authenticatedUserProfile.updatedAt}`,
|
||||
}
|
||||
: { id: authenticatedUserId, email: authenticatedUserId },
|
||||
}
|
||||
: {}),
|
||||
...(authenticatedUserId ? { user: buildAuthenticatedPresenceUser() } : {}),
|
||||
reason: "connect",
|
||||
});
|
||||
incrementPresenceVersion();
|
||||
@@ -552,4 +571,36 @@ export async function attachAuthenticatedGatewayConnect(
|
||||
}
|
||||
|
||||
await sendGatewayHello(context, state, pluginSurfaceUrls);
|
||||
|
||||
const tailscaleProfilePic = authResult.tailscaleIdentity?.profilePic;
|
||||
const tailscaleProfileId = authenticatedUserProfile?.profileId;
|
||||
if (tailscaleProfileId && !authenticatedUserProfile?.hasAvatar && tailscaleProfilePic) {
|
||||
runDetachedConnectWork(
|
||||
async () => {
|
||||
const updated = await adoptTailscaleProfileAvatar(tailscaleProfileId, tailscaleProfilePic);
|
||||
if (!updated.avatarMime) {
|
||||
return;
|
||||
}
|
||||
authenticatedUserProfile = {
|
||||
profileId: updated.id,
|
||||
displayName: updated.displayName,
|
||||
hasAvatar: true,
|
||||
updatedAt: updated.updatedAt,
|
||||
};
|
||||
nextClient.authenticatedUserProfile = authenticatedUserProfile;
|
||||
if (isClosed() || !presenceKey) {
|
||||
return;
|
||||
}
|
||||
upsertPresence(presenceKey, { user: buildAuthenticatedPresenceUser() });
|
||||
const requestContext = buildRequestContext();
|
||||
broadcastPresenceSnapshot({
|
||||
broadcast: requestContext.broadcast,
|
||||
incrementPresenceVersion: requestContext.incrementPresenceVersion,
|
||||
getHealthVersion: requestContext.getHealthVersion,
|
||||
});
|
||||
},
|
||||
(error) =>
|
||||
logGateway.warn(`Tailscale avatar adoption failed conn=${connId}: ${formatForLog(error)}`),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,9 @@ const {
|
||||
getHealthVersionMock,
|
||||
incrementPresenceVersionMock,
|
||||
loadConfigMock,
|
||||
adoptTailscaleProfileAvatarMock,
|
||||
ensureProfileForEmailMock,
|
||||
resolveConnectAuthStateMock,
|
||||
upsertPresenceMock,
|
||||
} = vi.hoisted(() => ({
|
||||
buildGatewaySnapshotMock: vi.fn(() => ({
|
||||
@@ -56,14 +58,27 @@ const {
|
||||
},
|
||||
},
|
||||
})),
|
||||
adoptTailscaleProfileAvatarMock: vi.fn(),
|
||||
ensureProfileForEmailMock: vi.fn(),
|
||||
resolveConnectAuthStateMock: vi.fn(),
|
||||
upsertPresenceMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../../state/user-profiles.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../../state/user-profiles.js")>();
|
||||
adoptTailscaleProfileAvatarMock.mockImplementation(actual.adoptTailscaleProfileAvatar);
|
||||
ensureProfileForEmailMock.mockImplementation(actual.ensureProfileForEmail);
|
||||
return { ...actual, ensureProfileForEmail: ensureProfileForEmailMock };
|
||||
return {
|
||||
...actual,
|
||||
adoptTailscaleProfileAvatar: adoptTailscaleProfileAvatarMock,
|
||||
ensureProfileForEmail: ensureProfileForEmailMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./auth-context.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./auth-context.js")>();
|
||||
resolveConnectAuthStateMock.mockImplementation(actual.resolveConnectAuthState);
|
||||
return { ...actual, resolveConnectAuthState: resolveConnectAuthStateMock };
|
||||
});
|
||||
|
||||
vi.mock("../../../config/config.js", () => ({
|
||||
@@ -711,6 +726,89 @@ describe("attachGatewayWsMessageHandler post-connect health refresh", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("registers a verified profile before detached Tailscale avatar adoption completes", async () => {
|
||||
await withOpenClawTestState({ label: "gateway-tailscale-avatar-detached" }, async () => {
|
||||
let resolveAvatar:
|
||||
| ((profile: {
|
||||
id: string;
|
||||
displayName: string | null;
|
||||
avatarMime: "image/png" | "image/jpeg" | "image/webp" | null;
|
||||
mergedInto: string | null;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}) => void)
|
||||
| undefined;
|
||||
adoptTailscaleProfileAvatarMock.mockImplementationOnce(
|
||||
async () =>
|
||||
await new Promise((resolve) => {
|
||||
resolveAvatar = resolve;
|
||||
}),
|
||||
);
|
||||
resolveConnectAuthStateMock.mockResolvedValueOnce({
|
||||
authResult: {
|
||||
ok: true,
|
||||
method: "tailscale",
|
||||
user: "ada@github",
|
||||
tailscaleIdentity: {
|
||||
login: "ada@github",
|
||||
name: "Ada Lovelace",
|
||||
profilePic: "https://avatars.example.test/ada.png",
|
||||
},
|
||||
},
|
||||
authOk: true,
|
||||
authMethod: "tailscale",
|
||||
sharedAuthOk: true,
|
||||
sharedAuthProvided: false,
|
||||
});
|
||||
const harness = attachGatewayHarness({
|
||||
connId: "conn-tailscale-avatar-detached",
|
||||
connectNonce: "nonce-tailscale-avatar-detached",
|
||||
});
|
||||
|
||||
harness.sendConnect("connect-tailscale-avatar-detached", {
|
||||
minProtocol: PROTOCOL_VERSION,
|
||||
maxProtocol: PROTOCOL_VERSION,
|
||||
client: {
|
||||
id: "gateway-client",
|
||||
version: "dev",
|
||||
platform: "test",
|
||||
mode: "backend",
|
||||
},
|
||||
role: "operator",
|
||||
caps: [],
|
||||
});
|
||||
|
||||
await waitForFast(() => {
|
||||
expect(harness.client).toMatchObject({
|
||||
authenticatedUserId: "ada@github",
|
||||
authenticatedUserIsTailscaleProvider: true,
|
||||
authenticatedUserProfile: { displayName: "Ada Lovelace", hasAvatar: false },
|
||||
});
|
||||
expect(adoptTailscaleProfileAvatarMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
expect(harness.socketSend).toHaveBeenCalled();
|
||||
|
||||
const profile = (
|
||||
harness.client as {
|
||||
authenticatedUserProfile: { profileId: string; displayName: string; updatedAt: number };
|
||||
}
|
||||
).authenticatedUserProfile;
|
||||
resolveAvatar?.({
|
||||
id: profile.profileId,
|
||||
displayName: profile.displayName,
|
||||
avatarMime: "image/png",
|
||||
mergedInto: null,
|
||||
createdAt: profile.updatedAt,
|
||||
updatedAt: profile.updatedAt + 1,
|
||||
});
|
||||
await waitForFast(() => {
|
||||
expect(harness.client).toMatchObject({
|
||||
authenticatedUserProfile: { hasAvatar: true, updatedAt: profile.updatedAt + 1 },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to email identity when durable profile resolution fails", async () => {
|
||||
ensureProfileForEmailMock.mockImplementationOnce(() => {
|
||||
throw new Error("profile store unavailable");
|
||||
|
||||
@@ -37,6 +37,8 @@ export type GatewayWsClient = PluginNodeCapabilityClient & {
|
||||
sharedGatewaySessionGeneration?: string;
|
||||
presenceKey?: string;
|
||||
authenticatedUserId?: string;
|
||||
/** Verified Tailscale provider identity; generic proxy identities must not infer this. */
|
||||
authenticatedUserIsTailscaleProvider?: boolean;
|
||||
authenticatedUserProfile?: {
|
||||
profileId: string;
|
||||
displayName: string | null;
|
||||
|
||||
@@ -20,4 +20,15 @@ CREATE TABLE IF NOT EXISTS user_profile_emails (
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_profile_emails_profile_id
|
||||
ON user_profile_emails(profile_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_profile_identities (
|
||||
provider TEXT NOT NULL,
|
||||
subject TEXT NOT NULL,
|
||||
profile_id TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (provider, subject)
|
||||
) STRICT;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_profile_identities_profile_id
|
||||
ON user_profile_identities(profile_id);
|
||||
`;
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { fileTypeFromBuffer } from "file-type";
|
||||
import { readRemoteMediaBuffer, type FetchLike } from "../media/fetch.js";
|
||||
|
||||
export const MAX_USER_PROFILE_AVATAR_BYTES = 512 * 1024;
|
||||
export const USER_PROFILE_AVATAR_MIME_TYPES = ["image/png", "image/jpeg", "image/webp"] as const;
|
||||
export type UserProfileAvatarMime = (typeof USER_PROFILE_AVATAR_MIME_TYPES)[number];
|
||||
|
||||
const TAILSCALE_AVATAR_FETCH_TIMEOUT_MS = 5_000;
|
||||
const TAILSCALE_AVATAR_MAX_REDIRECTS = 3;
|
||||
|
||||
export type TailscaleAvatarFetchOptions = {
|
||||
fetchImpl?: FetchLike;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
function toAvatarMime(value: string | undefined): UserProfileAvatarMime | null {
|
||||
return USER_PROFILE_AVATAR_MIME_TYPES.includes(value as UserProfileAvatarMime)
|
||||
? (value as UserProfileAvatarMime)
|
||||
: null;
|
||||
}
|
||||
|
||||
export async function fetchTailscaleAvatar(
|
||||
url: string,
|
||||
options: TailscaleAvatarFetchOptions,
|
||||
): Promise<{ bytes: Buffer; mime: UserProfileAvatarMime } | null> {
|
||||
try {
|
||||
const timeoutMs = options.timeoutMs ?? TAILSCALE_AVATAR_FETCH_TIMEOUT_MS;
|
||||
const loaded = await readRemoteMediaBuffer({
|
||||
url,
|
||||
fetchImpl: options.fetchImpl,
|
||||
maxBytes: MAX_USER_PROFILE_AVATAR_BYTES,
|
||||
maxRedirects: TAILSCALE_AVATAR_MAX_REDIRECTS,
|
||||
timeoutMs,
|
||||
responseHeaderTimeoutMs: timeoutMs,
|
||||
readIdleTimeoutMs: timeoutMs,
|
||||
requestInit: { headers: { Accept: USER_PROFILE_AVATAR_MIME_TYPES.join(",") } },
|
||||
});
|
||||
const mime = toAvatarMime(loaded.contentType);
|
||||
const detected = await fileTypeFromBuffer(loaded.buffer);
|
||||
return mime && detected?.mime === mime ? { bytes: loaded.buffer, mime } : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { classifyTailscaleLogin } from "./user-profiles-tailscale-login.js";
|
||||
|
||||
describe("Tailscale profile login classification", () => {
|
||||
it.each([
|
||||
["user@github", { kind: "provider", provider: "github", subject: "user" }],
|
||||
["USER@PASSKEY", { kind: "provider", provider: "passkey", subject: "user" }],
|
||||
["person@gmail.com", { kind: "email", email: "person@gmail.com" }],
|
||||
["person@alias@gmail.com", { kind: "email", email: "person@alias@gmail.com" }],
|
||||
["usér@github", { kind: "provider", provider: "github", subject: "usér" }],
|
||||
["person@例え.テスト", { kind: "email", email: "person@例え.テスト" }],
|
||||
["", { kind: "invalid" }],
|
||||
["missing-at", { kind: "invalid" }],
|
||||
["@github", { kind: "invalid" }],
|
||||
["user@", { kind: "invalid" }],
|
||||
])("classifies %j", (login, expected) => {
|
||||
expect(classifyTailscaleLogin(login)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
type ClassifiedTailscaleLogin =
|
||||
| { kind: "email"; email: string }
|
||||
| { kind: "provider"; provider: string; subject: string }
|
||||
| { kind: "invalid" };
|
||||
|
||||
export type TailscaleProfileIdentity = {
|
||||
login: string;
|
||||
name?: string;
|
||||
profilePic?: string;
|
||||
};
|
||||
|
||||
/** Classify Tailscale's documented email or email-ish LoginName representation. */
|
||||
export function classifyTailscaleLogin(login: string): ClassifiedTailscaleLogin {
|
||||
const normalized = login.trim();
|
||||
const separator = normalized.lastIndexOf("@");
|
||||
if (separator <= 0 || separator === normalized.length - 1) {
|
||||
return { kind: "invalid" };
|
||||
}
|
||||
const subject = normalized.slice(0, separator);
|
||||
const suffix = normalized.slice(separator + 1);
|
||||
return suffix.includes(".")
|
||||
? { kind: "email", email: normalized }
|
||||
: { kind: "provider", provider: suffix.toLowerCase(), subject: subject.toLowerCase() };
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// Doctor-only repair for Tailscale provider logins written as email aliases.
|
||||
import {
|
||||
executeSqliteQuerySync,
|
||||
executeSqliteQueryTakeFirstSync,
|
||||
getNodeSqliteKysely,
|
||||
} from "../infra/kysely-sync.js";
|
||||
import { tableExists } from "./openclaw-state-db-schema-helpers.js";
|
||||
import {
|
||||
openOpenClawStateDatabase,
|
||||
runOpenClawStateWriteTransaction,
|
||||
type OpenClawStateDatabaseOptions,
|
||||
} from "./openclaw-state-db.js";
|
||||
import { classifyTailscaleLogin } from "./user-profiles-tailscale-login.js";
|
||||
import { ensureUserProfilesSchema, type UserProfilesDatabase } from "./user-profiles.js";
|
||||
|
||||
type UserProfileIdentityMigrationResult = {
|
||||
changes: string[];
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
export function migrateLegacyTailscaleProfileIdentities(
|
||||
options: OpenClawStateDatabaseOptions = {},
|
||||
): UserProfileIdentityMigrationResult {
|
||||
const database = openOpenClawStateDatabase(options);
|
||||
if (!tableExists(database.db, "user_profile_emails")) {
|
||||
return { changes: [], warnings: [] };
|
||||
}
|
||||
const kysely = getNodeSqliteKysely<UserProfilesDatabase>(database.db);
|
||||
// Legacy aliases did not record auth provenance. Doctor intentionally applies
|
||||
// the current LoginName classifier while preserving any conflicting alias.
|
||||
const legacyRows = executeSqliteQuerySync(
|
||||
database.db,
|
||||
kysely
|
||||
.selectFrom("user_profile_emails")
|
||||
.select(["email", "profile_id", "created_at"])
|
||||
.orderBy("email", "asc"),
|
||||
).rows.flatMap((row) => {
|
||||
const classified = classifyTailscaleLogin(row.email);
|
||||
return classified.kind === "provider" ? [{ ...row, ...classified }] : [];
|
||||
});
|
||||
if (legacyRows.length === 0) {
|
||||
return { changes: [], warnings: [] };
|
||||
}
|
||||
|
||||
ensureUserProfilesSchema(options);
|
||||
return runOpenClawStateWriteTransaction(
|
||||
({ db }) => {
|
||||
const transactionKysely = getNodeSqliteKysely<UserProfilesDatabase>(db);
|
||||
let migrated = 0;
|
||||
const warnings: string[] = [];
|
||||
for (const row of legacyRows) {
|
||||
executeSqliteQuerySync(
|
||||
db,
|
||||
transactionKysely
|
||||
.insertInto("user_profile_identities")
|
||||
.values({
|
||||
provider: row.provider,
|
||||
subject: row.subject,
|
||||
profile_id: row.profile_id,
|
||||
created_at: row.created_at,
|
||||
})
|
||||
.onConflict((conflict) => conflict.columns(["provider", "subject"]).doNothing()),
|
||||
);
|
||||
const identity = executeSqliteQueryTakeFirstSync(
|
||||
db,
|
||||
transactionKysely
|
||||
.selectFrom("user_profile_identities")
|
||||
.select("profile_id")
|
||||
.where("provider", "=", row.provider)
|
||||
.where("subject", "=", row.subject),
|
||||
);
|
||||
if (identity?.profile_id !== row.profile_id) {
|
||||
warnings.push(
|
||||
`Kept legacy profile login ${row.email}: ${row.provider} identity is already linked to another profile.`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
executeSqliteQuerySync(
|
||||
db,
|
||||
transactionKysely
|
||||
.deleteFrom("user_profile_emails")
|
||||
.where("email", "=", row.email)
|
||||
.where("profile_id", "=", row.profile_id),
|
||||
);
|
||||
migrated += 1;
|
||||
}
|
||||
return {
|
||||
changes:
|
||||
migrated > 0
|
||||
? [
|
||||
`Moved ${migrated} legacy Tailscale provider ${migrated === 1 ? "identity" : "identities"} out of user profile email aliases.`,
|
||||
]
|
||||
: [],
|
||||
warnings,
|
||||
};
|
||||
},
|
||||
options,
|
||||
{ operationLabel: "user-profiles.migrate-legacy-identities" },
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,18 @@
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { mkdtempSync, readFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { tableExists } from "./openclaw-state-db-schema-helpers.js";
|
||||
import { OPENCLAW_STATE_SCHEMA_VERSION } from "./openclaw-state-db.js";
|
||||
import {
|
||||
closeOpenClawStateDatabaseForTest,
|
||||
openOpenClawStateDatabase,
|
||||
} from "./openclaw-state-db.js";
|
||||
import { migrateLegacyTailscaleProfileIdentities } from "./user-profiles-tailscale-migration.js";
|
||||
import {
|
||||
adoptTailscaleProfileAvatar,
|
||||
ensureProfileForEmail,
|
||||
ensureProfileForTailscaleIdentity,
|
||||
formatUserProfileAvatarEtag,
|
||||
getProfileAvatar,
|
||||
linkEmail,
|
||||
@@ -27,6 +31,25 @@ function stateOptions() {
|
||||
return { path };
|
||||
}
|
||||
|
||||
function fixtureImage(path: string): Buffer {
|
||||
return readFileSync(join(process.cwd(), path));
|
||||
}
|
||||
|
||||
function imageFetch(bytes: Uint8Array, mime: string) {
|
||||
return vi.fn(
|
||||
async () => new Response(Uint8Array.from(bytes).buffer, { headers: { "content-type": mime } }),
|
||||
);
|
||||
}
|
||||
|
||||
async function ensureTailscaleProfileWithAvatar(
|
||||
identity: Parameters<typeof ensureProfileForTailscaleIdentity>[0],
|
||||
options: Parameters<typeof ensureProfileForTailscaleIdentity>[1],
|
||||
fetchOptions: Parameters<typeof adoptTailscaleProfileAvatar>[3],
|
||||
) {
|
||||
const profile = ensureProfileForTailscaleIdentity(identity, options);
|
||||
return await adoptTailscaleProfileAvatar(profile.id, identity.profilePic, options, fetchOptions);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
@@ -35,12 +58,22 @@ afterEach(() => {
|
||||
describe("user profiles", () => {
|
||||
it("lazily ensures and resolves lowercased email aliases idempotently", () => {
|
||||
const options = stateOptions();
|
||||
expect(tableExists(openOpenClawStateDatabase(options).db, "user_profiles")).toBe(false);
|
||||
const database = openOpenClawStateDatabase(options).db;
|
||||
const versionBefore = database.prepare("PRAGMA user_version").get()?.user_version;
|
||||
expect(tableExists(database, "user_profiles")).toBe(false);
|
||||
expect(tableExists(database, "user_profile_identities")).toBe(false);
|
||||
|
||||
const first = ensureProfileForEmail(" Ada@Example.COM ", options);
|
||||
const second = ensureProfileForEmail("ada@example.com", options);
|
||||
|
||||
expect(tableExists(openOpenClawStateDatabase(options).db, "user_profiles")).toBe(true);
|
||||
expect(tableExists(openOpenClawStateDatabase(options).db, "user_profile_identities")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
openOpenClawStateDatabase(options).db.prepare("PRAGMA user_version").get()?.user_version,
|
||||
).toBe(versionBefore);
|
||||
expect(OPENCLAW_STATE_SCHEMA_VERSION).toBe(6);
|
||||
expect(second).toEqual(first);
|
||||
expect(ensureProfileForEmail("ADA@example.com", options)).toEqual(first);
|
||||
expect(listProfiles(options)).toEqual([
|
||||
@@ -48,6 +81,65 @@ describe("user profiles", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("resolves provider identities without storing them as emails", () => {
|
||||
const options = stateOptions();
|
||||
|
||||
const first = ensureProfileForTailscaleIdentity(
|
||||
{ login: "Ada@GitHub", name: "Ada Lovelace" },
|
||||
options,
|
||||
);
|
||||
const second = ensureProfileForTailscaleIdentity(
|
||||
{ login: "ada@github", name: "Different Provider Name" },
|
||||
options,
|
||||
);
|
||||
|
||||
expect(second.id).toBe(first.id);
|
||||
expect(second.displayName).toBe("Ada Lovelace");
|
||||
expect(listProfiles(options)).toEqual([
|
||||
expect.objectContaining({ id: first.id, emails: [], displayName: "Ada Lovelace" }),
|
||||
]);
|
||||
expect(
|
||||
openOpenClawStateDatabase(options)
|
||||
.db.prepare(
|
||||
"SELECT provider, subject, profile_id FROM user_profile_identities ORDER BY provider, subject",
|
||||
)
|
||||
.all(),
|
||||
).toEqual([{ provider: "github", subject: "ada", profile_id: first.id }]);
|
||||
});
|
||||
|
||||
it("keeps dotted Tailscale logins on the email alias path", () => {
|
||||
const options = stateOptions();
|
||||
|
||||
const profile = ensureProfileForTailscaleIdentity(
|
||||
{ login: "Person@Gmail.COM", name: "Person Example" },
|
||||
options,
|
||||
);
|
||||
|
||||
expect(ensureProfileForEmail("person@gmail.com", options).id).toBe(profile.id);
|
||||
expect(profile.displayName).toBe("Person Example");
|
||||
expect(listProfiles(options)).toEqual([
|
||||
expect.objectContaining({ id: profile.id, emails: ["person@gmail.com"] }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("adopts a Tailscale name only while the display-name slot is empty", () => {
|
||||
const options = stateOptions();
|
||||
const profile = ensureProfileForTailscaleIdentity(
|
||||
{ login: "ada@github", name: "Ada Provider" },
|
||||
options,
|
||||
);
|
||||
|
||||
setDisplayName(profile.id, null, options);
|
||||
expect(
|
||||
ensureProfileForTailscaleIdentity({ login: "ada@github", name: "Ada Adopted" }, options),
|
||||
).toMatchObject({ displayName: "Ada Adopted" });
|
||||
|
||||
setDisplayName(profile.id, "User Chosen", options);
|
||||
expect(
|
||||
ensureProfileForTailscaleIdentity({ login: "ada@github", name: "Provider Changed" }, options),
|
||||
).toMatchObject({ displayName: "User Chosen" });
|
||||
});
|
||||
|
||||
it("moves aliases and leaves an aliasless source profile as a one-hop tombstone", () => {
|
||||
const options = stateOptions();
|
||||
const source = ensureProfileForEmail("source@example.com", options);
|
||||
@@ -149,6 +241,183 @@ describe("user profiles", () => {
|
||||
expect(profile.displayName).toHaveLength(256);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["image/png", "ui/public/favicon-32.png"],
|
||||
["image/jpeg", "docs/whatsapp-openclaw.jpg"],
|
||||
["image/webp", "ui/public/app-art/android.webp"],
|
||||
])("adopts a bounded %s Tailscale avatar", async (mime, path) => {
|
||||
const options = stateOptions();
|
||||
const bytes = fixtureImage(path);
|
||||
|
||||
const profile = await ensureTailscaleProfileWithAvatar(
|
||||
{
|
||||
login: `avatar-${mime.slice("image/".length)}@github`,
|
||||
name: "Avatar User",
|
||||
profilePic: "https://avatars.example.test/profile",
|
||||
},
|
||||
options,
|
||||
{ fetchImpl: imageFetch(bytes, mime) },
|
||||
);
|
||||
|
||||
expect(profile.avatarMime).toBe(mime);
|
||||
const stored = getProfileAvatar(profile.id, options);
|
||||
expect(stored).toMatchObject({
|
||||
mime,
|
||||
sha256: expect.stringMatching(/^[a-f0-9]{64}$/u),
|
||||
});
|
||||
expect(Buffer.from(stored?.bytes ?? []).equals(bytes)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "oversized",
|
||||
fetchImpl: vi.fn(
|
||||
async () =>
|
||||
new Response("x", {
|
||||
headers: {
|
||||
"content-length": String(512 * 1024 + 1),
|
||||
"content-type": "image/png",
|
||||
},
|
||||
}),
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "wrong-type",
|
||||
fetchImpl: vi.fn(
|
||||
async () => new Response("not an image", { headers: { "content-type": "text/plain" } }),
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "failed-fetch",
|
||||
fetchImpl: vi.fn(async () => {
|
||||
throw new Error("network unavailable");
|
||||
}),
|
||||
},
|
||||
])("keeps the avatar empty after a $name fetch", async ({ fetchImpl }) => {
|
||||
const options = stateOptions();
|
||||
|
||||
const profile = await ensureTailscaleProfileWithAvatar(
|
||||
{
|
||||
login: "avatar-failure@github",
|
||||
name: "Still Authenticated",
|
||||
profilePic: "https://avatars.example.test/profile",
|
||||
},
|
||||
options,
|
||||
{ fetchImpl },
|
||||
);
|
||||
|
||||
expect(profile).toMatchObject({ displayName: "Still Authenticated", avatarMime: null });
|
||||
expect(getProfileAvatar(profile.id, options)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("times out avatar adoption without failing profile resolution", async () => {
|
||||
const options = stateOptions();
|
||||
const fetchImpl = vi.fn(
|
||||
async (_input: RequestInfo | URL, init?: RequestInit) =>
|
||||
await new Promise<Response>((_resolve, reject) => {
|
||||
init?.signal?.addEventListener(
|
||||
"abort",
|
||||
() =>
|
||||
reject(
|
||||
init.signal?.reason instanceof Error
|
||||
? init.signal.reason
|
||||
: new Error("avatar fetch aborted"),
|
||||
),
|
||||
{ once: true },
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const profile = await ensureTailscaleProfileWithAvatar(
|
||||
{
|
||||
login: "avatar-timeout@github",
|
||||
name: "Timeout User",
|
||||
profilePic: "https://avatars.example.test/profile",
|
||||
},
|
||||
options,
|
||||
{ fetchImpl, timeoutMs: 10 },
|
||||
);
|
||||
|
||||
expect(profile).toMatchObject({ displayName: "Timeout User", avatarMime: null });
|
||||
expect(getProfileAvatar(profile.id, options)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves a user avatar written while provider avatar bytes are in flight", async () => {
|
||||
const options = stateOptions();
|
||||
let resolveFetch: ((response: Response) => void) | undefined;
|
||||
const fetchImpl = vi.fn(
|
||||
async () =>
|
||||
await new Promise<Response>((resolve) => {
|
||||
resolveFetch = resolve;
|
||||
}),
|
||||
);
|
||||
const pending = ensureTailscaleProfileWithAvatar(
|
||||
{
|
||||
login: "avatar-race@github",
|
||||
name: "Race User",
|
||||
profilePic: "https://avatars.example.test/profile",
|
||||
},
|
||||
options,
|
||||
{ fetchImpl },
|
||||
);
|
||||
await vi.waitFor(() => expect(resolveFetch).toBeTypeOf("function"));
|
||||
const profileId = listProfiles(options)[0]?.id;
|
||||
expect(profileId).toBeTruthy();
|
||||
expect(setAvatar(profileId!, new Uint8Array([9, 8, 7]), "image/png", options).ok).toBe(true);
|
||||
|
||||
resolveFetch?.(
|
||||
new Response(Uint8Array.from(fixtureImage("ui/public/favicon-32.png")).buffer, {
|
||||
headers: { "content-type": "image/png" },
|
||||
}),
|
||||
);
|
||||
await pending;
|
||||
|
||||
expect(getProfileAvatar(profileId!, options)?.bytes).toEqual(new Uint8Array([9, 8, 7]));
|
||||
});
|
||||
|
||||
it("migrates legacy provider logins while preserving profiles and real emails", () => {
|
||||
const options = stateOptions();
|
||||
const provider = ensureProfileForEmail("user@github", options);
|
||||
const email = ensureProfileForEmail("person@gmail.com", options);
|
||||
setDisplayName(provider.id, "User Chosen", options);
|
||||
expect(setAvatar(provider.id, new Uint8Array([9, 8, 7]), "image/png", options).ok).toBe(true);
|
||||
|
||||
expect(migrateLegacyTailscaleProfileIdentities(options)).toEqual({
|
||||
changes: ["Moved 1 legacy Tailscale provider identity out of user profile email aliases."],
|
||||
warnings: [],
|
||||
});
|
||||
expect(migrateLegacyTailscaleProfileIdentities(options)).toEqual({ changes: [], warnings: [] });
|
||||
|
||||
const database = openOpenClawStateDatabase(options).db;
|
||||
expect(
|
||||
database.prepare("SELECT provider, subject, profile_id FROM user_profile_identities").all(),
|
||||
).toEqual([{ provider: "github", subject: "user", profile_id: provider.id }]);
|
||||
expect(database.prepare("SELECT email, profile_id FROM user_profile_emails").all()).toEqual([
|
||||
{ email: "person@gmail.com", profile_id: email.id },
|
||||
]);
|
||||
expect(listProfiles(options)).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: provider.id,
|
||||
displayName: "User Chosen",
|
||||
emails: [],
|
||||
hasAvatar: true,
|
||||
}),
|
||||
expect.objectContaining({ id: email.id, emails: ["person@gmail.com"] }),
|
||||
]),
|
||||
);
|
||||
expect(getProfileAvatar(provider.id, options)?.bytes).toEqual(new Uint8Array([9, 8, 7]));
|
||||
});
|
||||
|
||||
it("does not activate user-profile tables when Doctor has no legacy aliases", () => {
|
||||
const options = stateOptions();
|
||||
const database = openOpenClawStateDatabase(options).db;
|
||||
|
||||
expect(migrateLegacyTailscaleProfileIdentities(options)).toEqual({ changes: [], warnings: [] });
|
||||
expect(tableExists(database, "user_profiles")).toBe(false);
|
||||
expect(tableExists(database, "user_profile_identities")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects oversized and unsupported avatar uploads", () => {
|
||||
const options = stateOptions();
|
||||
const profile = ensureProfileForEmail("ada@example.com", options);
|
||||
|
||||
+207
-15
@@ -1,5 +1,5 @@
|
||||
import { createHash } from "node:crypto";
|
||||
// Durable user profiles and mutable login-email aliases in the shared state DB.
|
||||
// Durable user profiles plus typed login identities in the shared state DB.
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import { err, ok, type Result } from "@openclaw/normalization-core/result";
|
||||
import { sql } from "kysely";
|
||||
@@ -16,11 +16,17 @@ import {
|
||||
type OpenClawStateDatabaseOptions,
|
||||
} from "./openclaw-state-db.js";
|
||||
import { USER_PROFILES_SCHEMA_SQL } from "./user-profiles-schema.js";
|
||||
|
||||
const MAX_USER_PROFILE_AVATAR_BYTES = 512 * 1024;
|
||||
const USER_PROFILE_AVATAR_MIME_TYPES = ["image/png", "image/jpeg", "image/webp"] as const;
|
||||
|
||||
type UserProfileAvatarMime = (typeof USER_PROFILE_AVATAR_MIME_TYPES)[number];
|
||||
import {
|
||||
fetchTailscaleAvatar,
|
||||
MAX_USER_PROFILE_AVATAR_BYTES,
|
||||
USER_PROFILE_AVATAR_MIME_TYPES,
|
||||
type TailscaleAvatarFetchOptions,
|
||||
type UserProfileAvatarMime,
|
||||
} from "./user-profiles-tailscale-avatar.js";
|
||||
import {
|
||||
classifyTailscaleLogin,
|
||||
type TailscaleProfileIdentity,
|
||||
} from "./user-profiles-tailscale-login.js";
|
||||
|
||||
type UserProfile = {
|
||||
id: string;
|
||||
@@ -58,7 +64,7 @@ export class UserProfileNotFoundError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
type UserProfilesDatabase = {
|
||||
export type UserProfilesDatabase = {
|
||||
user_profiles: {
|
||||
id: string;
|
||||
display_name: string | null;
|
||||
@@ -74,6 +80,12 @@ type UserProfilesDatabase = {
|
||||
profile_id: string;
|
||||
created_at: number;
|
||||
};
|
||||
user_profile_identities: {
|
||||
provider: string;
|
||||
subject: string;
|
||||
profile_id: string;
|
||||
created_at: number;
|
||||
};
|
||||
};
|
||||
|
||||
type UserProfileRow = UserProfilesDatabase["user_profiles"];
|
||||
@@ -91,7 +103,7 @@ function profileDb(db: DatabaseSync) {
|
||||
return getNodeSqliteKysely<UserProfilesDatabase>(db);
|
||||
}
|
||||
|
||||
function ensureUserProfilesSchema(options: OpenClawStateDatabaseOptions): void {
|
||||
export function ensureUserProfilesSchema(options: OpenClawStateDatabaseOptions): void {
|
||||
const database = openOpenClawStateDatabase(options);
|
||||
if (ensuredDatabases.has(database.db)) {
|
||||
return;
|
||||
@@ -116,6 +128,11 @@ function normalizeEmail(email: string): string {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeInitialDisplayName(name: string | undefined): string | null {
|
||||
const normalized = name?.trim();
|
||||
return normalized ? normalized.slice(0, MAX_USER_PROFILE_DISPLAY_NAME_LENGTH) : null;
|
||||
}
|
||||
|
||||
function toAvatarMime(value: string | null): UserProfileAvatarMime | null {
|
||||
return USER_PROFILE_AVATAR_MIME_TYPES.includes(value as UserProfileAvatarMime)
|
||||
? (value as UserProfileAvatarMime)
|
||||
@@ -232,18 +249,20 @@ export function getUserProfileListItem(
|
||||
return selectUserProfileListItemById(db, requireResolvedProfileById(db, profileId).id);
|
||||
}
|
||||
|
||||
/** Resolves an email alias or atomically creates its first durable profile. */
|
||||
export function ensureProfileForEmail(
|
||||
function ensureProfileForEmailWithInitialName(
|
||||
email: string,
|
||||
options: OpenClawStateDatabaseOptions = {},
|
||||
initialDisplayName: string | null,
|
||||
options: OpenClawStateDatabaseOptions,
|
||||
): UserProfile {
|
||||
const normalizedEmail = normalizeEmail(email);
|
||||
const profileId = generateSecureUuid();
|
||||
const now = Date.now();
|
||||
const displayName = (normalizedEmail.split("@", 1)[0] || normalizedEmail).slice(
|
||||
0,
|
||||
MAX_USER_PROFILE_DISPLAY_NAME_LENGTH,
|
||||
);
|
||||
const displayName =
|
||||
initialDisplayName ??
|
||||
(normalizedEmail.split("@", 1)[0] || normalizedEmail).slice(
|
||||
0,
|
||||
MAX_USER_PROFILE_DISPLAY_NAME_LENGTH,
|
||||
);
|
||||
ensureUserProfilesSchema(options);
|
||||
return runOpenClawStateWriteTransaction(
|
||||
({ db }) => {
|
||||
@@ -284,6 +303,179 @@ export function ensureProfileForEmail(
|
||||
);
|
||||
}
|
||||
|
||||
/** Resolves an email alias or atomically creates its first durable profile. */
|
||||
export function ensureProfileForEmail(
|
||||
email: string,
|
||||
options: OpenClawStateDatabaseOptions = {},
|
||||
): UserProfile {
|
||||
return ensureProfileForEmailWithInitialName(email, null, options);
|
||||
}
|
||||
|
||||
function ensureProfileForProviderIdentity(params: {
|
||||
provider: string;
|
||||
subject: string;
|
||||
initialDisplayName: string | null;
|
||||
options: OpenClawStateDatabaseOptions;
|
||||
}): UserProfile {
|
||||
const profileId = generateSecureUuid();
|
||||
const now = Date.now();
|
||||
ensureUserProfilesSchema(params.options);
|
||||
return runOpenClawStateWriteTransaction(
|
||||
({ db }) => {
|
||||
const kysely = profileDb(db);
|
||||
const existingIdentity = executeSqliteQueryTakeFirstSync(
|
||||
db,
|
||||
kysely
|
||||
.selectFrom("user_profile_identities")
|
||||
.select("profile_id")
|
||||
.where("provider", "=", params.provider)
|
||||
.where("subject", "=", params.subject),
|
||||
);
|
||||
if (existingIdentity) {
|
||||
return toUserProfile(requireResolvedProfileById(db, existingIdentity.profile_id));
|
||||
}
|
||||
const row: UserProfileRow = {
|
||||
id: profileId,
|
||||
display_name: params.initialDisplayName,
|
||||
avatar: null,
|
||||
avatar_mime: null,
|
||||
avatar_sha256: null,
|
||||
merged_into: null,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
executeSqliteQuerySync(db, kysely.insertInto("user_profiles").values(row));
|
||||
executeSqliteQuerySync(
|
||||
db,
|
||||
kysely.insertInto("user_profile_identities").values({
|
||||
provider: params.provider,
|
||||
subject: params.subject,
|
||||
profile_id: profileId,
|
||||
created_at: now,
|
||||
}),
|
||||
);
|
||||
return toUserProfile(row);
|
||||
},
|
||||
params.options,
|
||||
{ operationLabel: "user-profiles.ensure-identity" },
|
||||
);
|
||||
}
|
||||
|
||||
function adoptDisplayNameIfEmpty(
|
||||
profileId: string,
|
||||
displayName: string | null,
|
||||
options: OpenClawStateDatabaseOptions,
|
||||
): UserProfile {
|
||||
if (!displayName) {
|
||||
const { db } = openOpenClawStateDatabase(options);
|
||||
return toUserProfile(requireResolvedProfileById(db, profileId));
|
||||
}
|
||||
const now = Date.now();
|
||||
return runOpenClawStateWriteTransaction(
|
||||
({ db }) => {
|
||||
const profile = requireResolvedProfileById(db, profileId);
|
||||
if (profile.display_name !== null) {
|
||||
return toUserProfile(profile);
|
||||
}
|
||||
executeSqliteQuerySync(
|
||||
db,
|
||||
profileDb(db)
|
||||
.updateTable("user_profiles")
|
||||
.set({ display_name: displayName, updated_at: now })
|
||||
.where("id", "=", profile.id),
|
||||
);
|
||||
return toUserProfile({ ...profile, display_name: displayName, updated_at: now });
|
||||
},
|
||||
options,
|
||||
{ operationLabel: "user-profiles.adopt-display-name" },
|
||||
);
|
||||
}
|
||||
|
||||
async function adoptAvatarIfEmpty(params: {
|
||||
profileId: string;
|
||||
profilePic: string | undefined;
|
||||
options: OpenClawStateDatabaseOptions;
|
||||
fetchOptions: TailscaleAvatarFetchOptions;
|
||||
}): Promise<UserProfile> {
|
||||
const { db } = openOpenClawStateDatabase(params.options);
|
||||
const beforeFetch = requireResolvedProfileById(db, params.profileId);
|
||||
if (beforeFetch.avatar !== null || !params.profilePic) {
|
||||
return toUserProfile(beforeFetch);
|
||||
}
|
||||
const avatar = await fetchTailscaleAvatar(params.profilePic, params.fetchOptions);
|
||||
if (!avatar) {
|
||||
return toUserProfile(requireResolvedProfileById(db, params.profileId));
|
||||
}
|
||||
const now = Date.now();
|
||||
return runOpenClawStateWriteTransaction(
|
||||
({ db: transactionDb }) => {
|
||||
const profile = requireResolvedProfileById(transactionDb, params.profileId);
|
||||
if (profile.avatar !== null) {
|
||||
return toUserProfile(profile);
|
||||
}
|
||||
const sha256 = createHash("sha256").update(avatar.bytes).digest("hex");
|
||||
executeSqliteQuerySync(
|
||||
transactionDb,
|
||||
profileDb(transactionDb)
|
||||
.updateTable("user_profiles")
|
||||
.set({
|
||||
avatar: avatar.bytes,
|
||||
avatar_mime: avatar.mime,
|
||||
avatar_sha256: sha256,
|
||||
updated_at: now,
|
||||
})
|
||||
.where("id", "=", profile.id),
|
||||
);
|
||||
return toUserProfile({
|
||||
...profile,
|
||||
avatar: avatar.bytes,
|
||||
avatar_mime: avatar.mime,
|
||||
avatar_sha256: sha256,
|
||||
updated_at: now,
|
||||
});
|
||||
},
|
||||
params.options,
|
||||
{ operationLabel: "user-profiles.adopt-avatar" },
|
||||
);
|
||||
}
|
||||
|
||||
/** Resolves a verified Tailscale login and adopts its display name into an empty field. */
|
||||
export function ensureProfileForTailscaleIdentity(
|
||||
identity: TailscaleProfileIdentity,
|
||||
options: OpenClawStateDatabaseOptions = {},
|
||||
): UserProfile {
|
||||
const classified = classifyTailscaleLogin(identity.login);
|
||||
if (classified.kind === "invalid") {
|
||||
throw new TypeError("Tailscale login must contain a nonempty subject and suffix");
|
||||
}
|
||||
const displayName = normalizeInitialDisplayName(identity.name);
|
||||
const resolved =
|
||||
classified.kind === "email"
|
||||
? ensureProfileForEmailWithInitialName(classified.email, displayName, options)
|
||||
: ensureProfileForProviderIdentity({
|
||||
provider: classified.provider,
|
||||
subject: classified.subject,
|
||||
initialDisplayName: displayName,
|
||||
options,
|
||||
});
|
||||
return adoptDisplayNameIfEmpty(resolved.id, displayName, options);
|
||||
}
|
||||
|
||||
/** Best-effort avatar adoption runs after authentication so remote I/O cannot delay login. */
|
||||
export async function adoptTailscaleProfileAvatar(
|
||||
profileId: string,
|
||||
profilePic: string | undefined,
|
||||
options: OpenClawStateDatabaseOptions = {},
|
||||
fetchOptions: TailscaleAvatarFetchOptions = {},
|
||||
): Promise<UserProfile> {
|
||||
return await adoptAvatarIfEmpty({
|
||||
profileId,
|
||||
profilePic,
|
||||
options,
|
||||
fetchOptions,
|
||||
});
|
||||
}
|
||||
|
||||
/** Links an email to a profile and retains an aliasless prior profile as a merge tombstone. */
|
||||
export function linkEmail(
|
||||
email: string,
|
||||
|
||||
Reference in New Issue
Block a user