{"record":{"id":"3ae56bcf6896a993","repo":"toeverything/AFFiNE","slug":"link-expired","errorCode":"link_expired","errorMessage":"The link has expired.","messagePattern":"The link has expired\\.","errorType":"exception","errorClass":"LinkExpired","httpStatus":400,"severity":"error","filePath":"packages/backend/server/src/core/auth/resolver.ts","lineNumber":109,"sourceCode":"\n    const userSession = await this.auth.createUserSession(user.id);\n\n    return {\n      sessionToken: userSession.sessionId,\n      token: userSession.sessionId,\n      refresh: '',\n    };\n  }\n\n  @Public()\n  @Mutation(() => Boolean)\n  async changePassword(\n    @Args('token') token: string,\n    @Args('newPassword') newPassword: string,\n    @Args('userId', { type: () => String, nullable: true }) userId?: string\n  ) {\n    if (!userId) {\n      throw new LinkExpired();\n    }\n\n    // NOTE: Set & Change password are using the same token type.\n    const valid = await this.models.verificationToken.verify(\n      TokenType.ChangePassword,\n      token,\n      {\n        credential: userId,\n      }\n    );\n\n    if (!valid) {\n      throw new InvalidEmailToken();\n    }\n\n    await this.auth.changePasswordAndRevokeSessions(userId, newPassword);\n\n    return true;","sourceCodeStart":91,"sourceCodeEnd":127,"githubUrl":"https://github.com/toeverything/AFFiNE/blob/b4c8548c09da21b2898443559a5b846f0ccf5dd8/packages/backend/server/src/core/auth/resolver.ts#L91-L127","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","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"],"exampleFix":"# before\nmutation {\n  changePassword(token: $token, newPassword: $newPassword)\n}\n\n# after\nmutation {\n  changePassword(token: $token, newPassword: $newPassword, userId: $userId) # from the reset link's query string\n}","handlingStrategy":"validation","validationCode":"function parseResetLink(url: string): { userId: string; token: string } {\n  const params = new URL(url).searchParams;\n  const userId = params.get('userId');\n  const token = params.get('token');\n  if (!userId || !token) throw new Error('reset link is incomplete or outdated - request a new email');\n  return { userId, token };\n}","typeGuard":null,"tryCatchPattern":"try {\n  await changePassword({ token, newPassword, userId });\n} catch (e) {\n  if (isAffineErrorCode(e, 'link_expired')) {\n    await requestPasswordReset(email); // fresh link carries both params\n  } else throw e;\n}","preventionTips":["Extract both userId and token from reset links; keep them intact through routing","Discard old reset emails after requesting a new one"],"tags":["auth","graphql","password-reset","expired-link"],"backgroundTag":"password-reset-link-invalid","analyzedSha":"b4c8548c09da21b2898443559a5b846f0ccf5dd8","analyzedAt":"2026-08-18T21:16:52.546Z","contentChangedAt":"2026-08-18T21:16:52.546Z","schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}