toeverything/AFFiNE · error · LinkExpired
link_expired
link_expired
Error message
The link has expired.
What it means
The changePassword GraphQL mutation requires a userId argument (nullable in the signature for legacy reasons); when omitted, the resolver immediately throws LinkExpired (link_expired, 'The link has expired'). Current password-reset/set emails embed the userId in the link - a request without it is treated as coming from an outdated or hand-built link.
Solutions
- Always pass the userId exactly as provided alongside the token in the reset link URL
- Request a fresh password-reset email so you get the current link format
- Make sure frontend routing preserves both query parameters (userId and token) from the link
- Discard old bookmarked reset links
Example fix
# before
mutation {
changePassword(token: $token, newPassword: $newPassword)
}
# after
mutation {
changePassword(token: $token, newPassword: $newPassword, userId: $userId) # from the reset link's query string
} Defensive patterns
Strategy: validation
Validate before calling
function parseResetLink(url: string): { userId: string; token: string } {
const params = new URL(url).searchParams;
const userId = params.get('userId');
const token = params.get('token');
if (!userId || !token) throw new Error('reset link is incomplete or outdated - request a new email');
return { userId, token };
} Try / catch
try {
await changePassword({ token, newPassword, userId });
} catch (e) {
if (isAffineErrorCode(e, 'link_expired')) {
await requestPasswordReset(email); // fresh link carries both params
} else throw e;
} Prevention
- Extract both userId and token from reset links; keep them intact through routing
- Discard old reset emails after requesting a new one
When it happens
Trigger: Calling changePassword(token, newPassword) without userId; a password-reset link from an old email format that only carried the token; constructing the mutation by hand instead of following the link URL; frontend losing the userId query parameter when routing.
Common situations: Users clicking years-old reset emails; custom frontends that parse only the token from the link; email templates predating the userId requirement; deep links truncated by chat clients.
Related errors
- invalid_email_token
- action_forbidden
- email_already_used
- email_token_not_found
- email_verification_required
AI-assisted analysis of toeverything/AFFiNE@b4c8548c09 (2026-08-18).
Data as JSON: /api/errors/3ae56bcf6896a993.
Report an issue: GitHub.
Appendix: source
Thrown at packages/backend/server/src/core/auth/resolver.ts:109
const userSession = await this.auth.createUserSession(user.id);
return {
sessionToken: userSession.sessionId,
token: userSession.sessionId,
refresh: '',
};
}
@Public()
@Mutation(() => Boolean)
async changePassword(
@Args('token') token: string,
@Args('newPassword') newPassword: string,
@Args('userId', { type: () => String, nullable: true }) userId?: string
) {
if (!userId) {
throw new LinkExpired();
}
// NOTE: Set & Change password are using the same token type.
const valid = await this.models.verificationToken.verify(
TokenType.ChangePassword,
token,
{
credential: userId,
}
);
if (!valid) {
throw new InvalidEmailToken();
}
await this.auth.changePasswordAndRevokeSessions(userId, newPassword);
return true;View on GitHub (pinned to b4c8548c09)